@cmgmyr/hive 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -66,6 +66,7 @@ function workerRow(lane, snapshot, nowMs, memory, nextMemory) {
66
66
  else {
67
67
  const since = state === "blocked" ? null : w.state_changed_at;
68
68
  head.push(`${state} ${duration(w, { label: state, since, lower_bound: false }, nowMs, memory, nextMemory)}`);
69
+ if (state !== "blocked" && w.activity?.label) head.push(w.activity.label);
69
70
  }
70
71
  const tail = [ago(w.age_seconds)];
71
72
  if (w.commits_ahead !== null && w.commits_ahead !== undefined) tail.push(`+${w.commits_ahead} commits`);
@@ -120,6 +121,8 @@ export function buildCrewView(snapshot, previousView, nowMs) {
120
121
  const { wakes } = snapshot;
121
122
  const workers = snapshot.lanes.filter((l) => l.worker).length;
122
123
  const footer = [{ text: nextLine(wakes.next, nowMs) }];
124
+ const questionsPending = snapshot.questions?.pending ?? 0;
125
+ if (questionsPending > 0) footer.push({ text: `questions pending: ${questionsPending}` });
123
126
  if (wakes.watching.length > 0) footer.push({ text: `watching: ${wakes.watching.map(watchText).join(", ")}` });
124
127
  else if (workers > 0 && !covered(snapshot)) footer.push({ text: "unwatched: no standing watch on running workers", amber: true });
125
128
  return {
@@ -1 +1 @@
1
- {"version":"1.10.0","sha":"9dd5fee","dirty":false,"build_id":"d7050d60f67cf44e2ca833e2ba422563b4e6b8fbdd68bccd51772e429cac1fa5"}
1
+ {"version":"1.11.0","sha":"7534ff6","dirty":false,"build_id":"bd31535e0eaed043ebe60b90c2e95925e21567baf82b5142e3a6c0e6908a58ac"}
package/dist/cli.js CHANGED
@@ -17,6 +17,7 @@ import { dataDir, db, migrate, storeSchemaAhead } from "./db.js";
17
17
  import { cacheIsStale, queryUpdate, readCachedUpdate, refreshUpdate, shouldAutoRefresh, updateLine } from "./updateCheck.js";
18
18
  import { checkoutUpgradeSteps, detectInstallShape, globalUpgradeSteps } from "./upgrade.js";
19
19
  import { isLowHeadroom, orphanLoginShellDetails, ptyHeadroom } from "./ptys.js";
20
+ import { staleHiveServers } from "./staleServers.js";
20
21
  import { addProject, agentProjectPin, assertQueenCrossProjectWrite, captureQueenWriteIdentity, currentActor, effectiveProjectId, findProjectForCwd, findProjectForDir, getProjectByPath, gitPrimaryRoot, getProject, listProjects, QUEEN_PROFILE, queenHomeDir, registeredAncestor, takeRegistrationNotice, } from "./context.js";
21
22
  import { confirmQueenWrite, listQueenAudit } from "./queenAudit.js";
22
23
  import { formatRowCounts, projectRowCounts, removeProject } from "./projectRemove.js";
@@ -37,8 +38,10 @@ import { collectCrew, crewStoreProblem } from "./crew.js";
37
38
  import { describeVisibility, formatLocal, processCounts } from "./dashboard.js";
38
39
  import { runningCommandRow, runningCommandRows, snapshotProcesses, stopAllProcesses, stopLine, stopProcess, STOP_REASONS, } from "./processes.js";
39
40
  import { activeProfile, agentVarKeys, BUILT_IN_PROJECT_YML, configHash, loadProjectYml, mergedProjectVars, NO_PROFILE, PROJECT_YML_KEYS, resolveCommandDir, resolveFirstMessage, } from "./projectYml.js";
40
- import { writeProjectPosture } from "./brief.js";
41
- import { carriesNameFlag, harnessFor, hasTranscriptSignal, paneClassifierFor, transcriptDirFor } from "./harnesses.js";
41
+ import { appendClaudeLeadArgs, crewPluginDir, isTrusted, leadIdentityEnv, renderLeadPosture } from "./leadLaunch.js";
42
+ import { handoffStatusSegment } from "./leadHandoff.js";
43
+ import { cmdLeadHandoff, runHandoffGrace, runHandoffStart } from "./leadHandoffCli.js";
44
+ import { harnessFor, hasTranscriptSignal, paneClassifierFor, transcriptDirFor } from "./harnesses.js";
42
45
  import { codexHomeDir, codexInstructionsPhrase, ensureCodexHome, reapCodexHome } from "./codexHome.js";
43
46
  import { FIRST_MESSAGE_SHA_ENV, firstMessageDigest } from "./firstMessage.js";
44
47
  import { ageSecondsSince, deriveProvenance, describeForHuman, describeLastLogEvent, humanizeAge, lastLogEvent, lastPermissionMode, reportsAgentStateLog, } from "./stateProvenance.js";
@@ -46,6 +49,7 @@ import { awaitingFirstPromptSql } from "./firstPrompt.js";
46
49
  import { checkoutRoot, createProfile, forkProfile, PROFILE_FILES, ProfileError, profileExists, profileFileNames, profileNames, profileStatus, readProfileFile, referencedPads, referencedPaths, renderProfileFile, resolveProfileFile, REWRITE_THRESHOLD, templateVars, userProfilesDir, } from "./profiles.js";
47
50
  import { getTodoDetail, listTodoSummaries, OPEN_BLOCKERS_SQL, TODO_STATUSES, } from "./tools/todos.js";
48
51
  import { createPad, getActivePadByName, listActivePads, overwritePadContent, } from "./tools/pads.js";
52
+ import { answerQuestion, listQuestions } from "./questions.js";
49
53
  function usage() {
50
54
  console.log(`hive — run a crew of Claude Code and Codex workers from one lead session
51
55
 
@@ -99,6 +103,10 @@ Usage:
99
103
  hive todos [--all] [--status <s>] [--tag <t>]
100
104
  list this project's todos; open work by default
101
105
  hive todo <id> print one todo in full, comments included
106
+ hive questions [--json] list pending and defaulted questions
107
+ hive answer <id> <answer> [--notes <text>]
108
+ record an answer to a pending question
109
+ use -- before an answer that starts with a dash
102
110
  hive backups list automatic store snapshots
103
111
  hive restore <name> [--yes] [--force] overwrite the live store from a snapshot
104
112
  hive project rm <id|path> [--yes]
@@ -109,6 +117,7 @@ Usage:
109
117
  hive kickoff [--explain] SessionStart hook output; silent unless this is a lead checkout
110
118
  hive profile [list|path|fork|create|read] standing instructions shared across projects
111
119
  hive statusline one-line store summary; silent outside hive projects
120
+ hive lead-handoff a lead confirms its persisted handoff pad (run from a handoff request)
112
121
 
113
122
  hive lead reads hive.yml from the project root when present; hive init
114
123
  writes this starter file (uncomment what you need):
@@ -204,12 +213,6 @@ async function ensureTrusted(projectId, name, command, dir, env) {
204
213
  db.prepare("INSERT OR IGNORE INTO command_trust (project_id, name, config_hash) VALUES (?, ?, ?)").run(projectId, name, hash);
205
214
  return true;
206
215
  }
207
- function isTrusted(projectId, name, command, dir, env) {
208
- const hash = configHash(name, command, dir, env);
209
- return !!db
210
- .prepare("SELECT 1 FROM command_trust WHERE project_id = ? AND name = ? AND config_hash = ?")
211
- .get(projectId, name, hash);
212
- }
213
216
  function startYmlCommand(project, name, proc, config) {
214
217
  if (isReservedAgentName(name)) {
215
218
  return `skipped: "lead" is reserved for this project's lead session and cannot be used as a process name`;
@@ -426,18 +429,9 @@ async function cmdLead(argv) {
426
429
  // Posture is rendered once, then delivered through whichever channel this harness has: a
427
430
  // claude lead gets it as --append-system-prompt-file below; a codex lead gets the identical
428
431
  // text as config.toml's developer_instructions, built after ensureLeadRow (it needs leadActorId).
429
- const profile = activeProfile(config);
430
- let renderedPosture = null;
431
- let postureSource;
432
- if (profile) {
433
- const posture = resolveProfileFile(profile, "posture.md");
434
- if (!posture) {
435
- console.log(`! profile "${profile}" has no posture.md on this machine; starting without it.`);
436
- }
437
- else {
438
- renderedPosture = renderProfileFile(profile, "posture.md", mergedProjectVars(config)) ?? "";
439
- postureSource = posture.source;
440
- }
432
+ const { profile, rendered: renderedPosture, source: postureSource } = renderLeadPosture(config);
433
+ if (profile && renderedPosture === null) {
434
+ console.log(`! profile "${profile}" has no posture.md on this machine; starting without it.`);
441
435
  }
442
436
  if (leadHarness.needsHome) {
443
437
  // Wired below, once leadActorId is known - a codex lead's hooks/posture/triage all live in
@@ -451,21 +445,19 @@ async function cmdLead(argv) {
451
445
  }
452
446
  }
453
447
  else {
454
- if (!carriesNameFlag(leadCommand.trim().split(/\s+/))) {
455
- leadCommand += ` --name ${shellQuote(project.name)}`;
456
- }
457
- leadCommand += ` ${leadHarness.briefDelivery.settingsArgs(hooksPath).map(shellQuote).join(" ")}`;
458
- if (config?.lead_sidebar === true && leadHarness.name === "claude") {
459
- leadCommand += ` ${["--plugin-dir", crewPluginDir()].map(shellQuote).join(" ")}`;
460
- }
448
+ ({ command: leadCommand, promptSuffix } = appendClaudeLeadArgs({
449
+ command: leadCommand,
450
+ harness: leadHarness,
451
+ projectId: project.id,
452
+ projectName: project.name,
453
+ hooksPath,
454
+ sidebarPluginDir: config?.lead_sidebar === true && leadHarness.name === "claude" ? crewPluginDir() : null,
455
+ posture: renderedPosture,
456
+ firstMessage,
457
+ }));
461
458
  if (renderedPosture !== null) {
462
- const posturePath = writeProjectPosture(project.id, renderedPosture);
463
- leadCommand += ` ${leadHarness.briefDelivery.systemPromptArgs(posturePath).map(shellQuote).join(" ")}`;
464
459
  console.log(`- profile: ${profile} (${postureSource} posture; see it with: hive posture)`);
465
460
  }
466
- if (leadHarness.initialPromptArgs && firstMessage !== "") {
467
- promptSuffix = ` ${leadHarness.initialPromptArgs(firstMessage).map(shellQuote).join(" ")}`;
468
- }
469
461
  }
470
462
  const windowName = project.name;
471
463
  const { agentId: leadAgentId, actorId: leadActorId, previousTarget, previousSocket, previousPanePid, previousCommand, previousCodexHome, casExpected, casSocket, casPid, } = ensureLeadRow(project, leadCommand);
@@ -475,14 +467,7 @@ async function cmdLead(argv) {
475
467
  // actually starts under it) and correct regardless of whether this invocation ends up creating
476
468
  // a pane or adopting one. Which home ends up orphaned - this one, or the previous invocation's -
477
469
  // is decided after createdPane is known, below.
478
- const leadEnv = {
479
- HIVE_AGENT_ID: leadActorId,
480
- HIVE_AGENT_NAME: LEAD_NAME,
481
- HIVE_LEAD: "1",
482
- HIVE_DATA_DIR: dataDir,
483
- HIVE_PROJECT_LOCK: "",
484
- HIVE_PROJECT_PATH: "",
485
- };
470
+ const leadEnv = leadIdentityEnv(leadActorId);
486
471
  let newCodexHomeKey;
487
472
  if (leadHarness.needsHome) {
488
473
  newCodexHomeKey = randomUUID();
@@ -1288,9 +1273,6 @@ function cmdProfile(argv) {
1288
1273
  throw e;
1289
1274
  }
1290
1275
  }
1291
- function crewPluginDir() {
1292
- return join(checkoutRoot, "claude-plugin", "crew");
1293
- }
1294
1276
  const DASHBOARD_OPENED_KV_KEY = "hive:dashboard_opened";
1295
1277
  const DASHBOARD_OPENED_TTL_SECONDS = 8 * 60 * 60;
1296
1278
  function maybeOpenDashboard(project, dashboardEnabled) {
@@ -1436,6 +1418,8 @@ function resolveNamedProcess(argv, verb) {
1436
1418
  }
1437
1419
  const LEAD_PANE_EXITED_HOOK = "pane-exited";
1438
1420
  const LEAD_PANE_EXITED_VERB = "lead-pane-exited";
1421
+ const LEAD_HANDOFF_GRACE_VERB = "lead-handoff-grace";
1422
+ const LEAD_HANDOFF_START_VERB = "lead-handoff-start";
1439
1423
  // tmux runs a hook with the SERVER's environment and no login shell, so a bare `hive` resolves to
1440
1424
  // nothing (the iTerm rule in .claude/rules/tmux-and-panes.md) and HIVE_DATA_DIR is not there at all
1441
1425
  // - without it the backstop would open the DEFAULT store and find no project. Absolute interpreter,
@@ -2364,6 +2348,18 @@ function cmdDoctor(argv) {
2364
2348
  }
2365
2349
  return `${dataDir} (schema v${n})`;
2366
2350
  });
2351
+ check("stale hive servers", () => {
2352
+ const { applied_at: appliedAt } = db.prepare("SELECT MAX(applied_at) AS applied_at FROM migrations").get();
2353
+ const result = staleHiveServers(appliedAt, dataDir);
2354
+ if ("skipped" in result) {
2355
+ info("stale hive servers", `check skipped: ${result.skipped}`);
2356
+ return "check skipped";
2357
+ }
2358
+ for (const server of result.servers) {
2359
+ warn("stale hive server", `pid ${server.pid} started ${server.startedAt} (parent pid ${server.ppid}: ${server.parentCommand ?? "unknown"})`, `command: ${server.command}`, "restart that session to load the current build");
2360
+ }
2361
+ return `${result.servers.length} stale server${result.servers.length === 1 ? "" : "s"}`;
2362
+ });
2367
2363
  check("hooks file", () => ensureHooksFile());
2368
2364
  const here = findProjectForCwd();
2369
2365
  reportDispatcher();
@@ -2810,11 +2806,13 @@ function cmdStatusline() {
2810
2806
  parts.push("store ahead (update hive)");
2811
2807
  let turns = null;
2812
2808
  let budget = null;
2809
+ let handoff = null;
2813
2810
  try {
2814
2811
  const actorId = process.env.HIVE_AGENT_ID;
2815
2812
  if (actorId) {
2816
- const lead = db.prepare("SELECT 1 AS lead FROM agents WHERE project_id = ? AND actor_id = ? AND kind = 'lead' AND status = 'running'").get(project.id, actorId);
2813
+ const lead = db.prepare("SELECT id AS lead FROM agents WHERE project_id = ? AND actor_id = ? AND kind = 'lead' AND status = 'running'").get(project.id, actorId);
2817
2814
  if (lead) {
2815
+ handoff = handoffStatusSegment(lead.lead);
2818
2816
  let inputPath = "";
2819
2817
  if (!process.stdin.isTTY) {
2820
2818
  try {
@@ -2834,6 +2832,8 @@ function cmdStatusline() {
2834
2832
  const color = budget === null ? "" : turns >= budget.stop ? "\x1b[31m" : turns >= budget.warn ? "\x1b[33m" : "";
2835
2833
  parts.push(`${color}turns ${turns}${color ? "\x1b[0m" : ""}`);
2836
2834
  }
2835
+ if (handoff !== null)
2836
+ parts.push(handoff);
2837
2837
  console.log(`\x1b[33m⬡\x1b[0m \x1b[2mhive:\x1b[0m ${parts.join(" \x1b[2m·\x1b[0m ")}`);
2838
2838
  }
2839
2839
  function padSlug(name) {
@@ -3006,7 +3006,7 @@ function cmdPortfolio(argv) {
3006
3006
  ` lead ${leadText(p)}; workers ${w.working} working, ${w.idle} idle, ${w.needs_input} needs input, ` +
3007
3007
  `${w.other} other, ${w.unreachable} unreachable, ${w.unconfirmed} unconfirmed\n` +
3008
3008
  ` todos ${t.open} open, ${t.in_progress} in progress, ${t.blocked} blocked, ${t.high} high; ` +
3009
- `needs human ${p.needs_human}; wakes ${p.wakes.pending} pending, ${p.wakes.overdue} overdue\n` +
3009
+ `needs human ${p.needs_human}; questions ${p.questions_pending} pending; wakes ${p.wakes.pending} pending, ${p.wakes.overdue} overdue\n` +
3010
3010
  ` last activity ${p.last_activity_at} UTC`);
3011
3011
  }
3012
3012
  const l = report.totals.lanes;
@@ -3314,6 +3314,67 @@ function cmdTodo(argv) {
3314
3314
  }
3315
3315
  }
3316
3316
  }
3317
+ function cmdQuestions(argv) {
3318
+ const parsed = parseArgs(argv, { flags: ["--json"] });
3319
+ if (parsed.positional.length > 0)
3320
+ parsed.unknown.push(parsed.positional[0]);
3321
+ rejectUnknownFlags("questions", parsed, "--json");
3322
+ const project = resolveProjectForRead("questions");
3323
+ const questions = listQuestions(project.id);
3324
+ if (parsed.flags.has("--json")) {
3325
+ console.log(JSON.stringify({ questions }));
3326
+ return;
3327
+ }
3328
+ if (questions.length === 0) {
3329
+ console.log("No pending or defaulted questions.");
3330
+ return;
3331
+ }
3332
+ for (const q of questions) {
3333
+ console.log(`#${q.id} [${q.state}] ${q.header}`);
3334
+ console.log(`Question: ${q.question}`);
3335
+ console.log(`Default: ${q.default_answer}`);
3336
+ if (q.options) {
3337
+ console.log("Options:");
3338
+ for (const option of q.options) {
3339
+ console.log(` ${option.label}${option.recommended ? " (Recommended)" : ""}: ${option.description}`);
3340
+ }
3341
+ }
3342
+ }
3343
+ }
3344
+ function cmdAnswer(argv) {
3345
+ const endOfFlags = argv.indexOf("--");
3346
+ const optionArgs = endOfFlags === -1 ? argv : argv.slice(0, endOfFlags);
3347
+ if (optionArgs.filter((arg) => arg === "--notes").length > 1) {
3348
+ console.error("hive answer: --notes may be passed only once.");
3349
+ process.exit(1);
3350
+ }
3351
+ const parsed = parseArgs(argv, { valued: ["--notes"] });
3352
+ rejectUnknownFlags("answer", parsed, "--notes <text>");
3353
+ requireFlagValues("answer", parsed);
3354
+ if (parsed.values.has("--notes") && parsed.values.get("--notes") === undefined) {
3355
+ console.error("hive answer: --notes requires a value.");
3356
+ process.exit(1);
3357
+ }
3358
+ if (parsed.positional.length !== 2) {
3359
+ console.error("hive answer: usage: hive answer <positive id> <one answer argument> [--notes <text>]");
3360
+ process.exit(1);
3361
+ }
3362
+ const [rawId, answer] = parsed.positional;
3363
+ if (!/^\d+$/.test(rawId) || !Number.isSafeInteger(Number(rawId)) || Number(rawId) <= 0) {
3364
+ console.error(`hive answer: question id must be a positive safe integer, got ${JSON.stringify(rawId)}.`);
3365
+ process.exit(1);
3366
+ }
3367
+ const project = resolveProjectForRead("answer");
3368
+ const id = Number(rawId);
3369
+ const receipt = answerQuestion(project.id, currentActor(), id, answer, parsed.values.get("--notes"));
3370
+ console.log(`Question #${receipt.question_id} answered.`);
3371
+ if (receipt.wake_id === null) {
3372
+ console.log(`No wake queued: ${receipt.wake_skipped ?? "the asking session could not be reached"}.`);
3373
+ }
3374
+ else {
3375
+ console.log(`Wake #${receipt.wake_id} queued for the asking session.`);
3376
+ }
3377
+ }
3317
3378
  function cmdPads() {
3318
3379
  const project = resolveProjectForRead("pads");
3319
3380
  const pads = listActivePads(project.id);
@@ -3435,7 +3496,10 @@ if (command === "--version" || command === "-v") {
3435
3496
  const COMMANDS = [
3436
3497
  "lead", "queen", "queen-audit", "init", "attach", "start", "stop", "show", "hide", "status", "portfolio", "crew", "next", "setup", "upgrade", "doctor",
3437
3498
  LEAD_PANE_EXITED_VERB,
3438
- "pads", "pad", "todos", "todo", "backups", "restore", "project", "runbook", "posture", "profile", "kickoff", "statusline",
3499
+ LEAD_HANDOFF_GRACE_VERB,
3500
+ LEAD_HANDOFF_START_VERB,
3501
+ "lead-handoff",
3502
+ "pads", "pad", "todos", "todo", "questions", "answer", "backups", "restore", "project", "runbook", "posture", "profile", "kickoff", "statusline",
3439
3503
  ];
3440
3504
  if (!COMMANDS.includes(command)) {
3441
3505
  if (command.startsWith("--")) {
@@ -3475,6 +3539,15 @@ try {
3475
3539
  case LEAD_PANE_EXITED_VERB:
3476
3540
  cmdLeadPaneExited(rest);
3477
3541
  break;
3542
+ case "lead-handoff":
3543
+ process.exitCode = await cmdLeadHandoff(rest);
3544
+ break;
3545
+ case LEAD_HANDOFF_GRACE_VERB:
3546
+ process.exitCode = await runHandoffGrace(rest);
3547
+ break;
3548
+ case LEAD_HANDOFF_START_VERB:
3549
+ process.exitCode = await runHandoffStart(rest);
3550
+ break;
3478
3551
  case "show":
3479
3552
  cmdShow(rest);
3480
3553
  break;
@@ -3517,6 +3590,12 @@ try {
3517
3590
  case "todo":
3518
3591
  cmdTodo(rest);
3519
3592
  break;
3593
+ case "questions":
3594
+ cmdQuestions(rest);
3595
+ break;
3596
+ case "answer":
3597
+ cmdAnswer(rest);
3598
+ break;
3520
3599
  case "backups":
3521
3600
  cmdBackups();
3522
3601
  break;
package/dist/context.js CHANGED
@@ -24,7 +24,7 @@ export function currentActor() {
24
24
  return cachedActorId;
25
25
  }
26
26
  const envId = process.env.HIVE_AGENT_ID;
27
- const id = envId ?? `user:${userInfo().username}`;
27
+ const id = envId ?? `${HUMAN_ACTOR_PREFIX}${userInfo().username}`;
28
28
  const name = process.env.HIVE_AGENT_NAME ?? id;
29
29
  const kind = envId ? "agent" : "human";
30
30
  db.prepare(`INSERT INTO actors (id, name, kind, tmux_pane) VALUES (?, ?, ?, ?)
@@ -330,6 +330,11 @@ export const QUEEN_REACH = {
330
330
  todo_unblock: "home",
331
331
  kv_set: "home",
332
332
  kv_delete: "home",
333
+ question_ask: "home",
334
+ question_get: "home",
335
+ question_list: "home",
336
+ question_update: "home",
337
+ question_answer: "home",
333
338
  lease_acquire: "home",
334
339
  lease_release: "home",
335
340
  agent_spawn: "home",
@@ -445,3 +450,4 @@ function queenRefusal(operation, targetProjectId, problem) {
445
450
  "queen may only todo_create, todo_comment, send text or wakes to its running lead, start its lead " +
446
451
  "with `hive lead <path>`, and run project_prune or `hive project rm` on it. Everything else there is its lead's to do.");
447
452
  }
453
+ export const HUMAN_ACTOR_PREFIX = "user:";
package/dist/crew.js CHANGED
@@ -5,6 +5,7 @@ import { awaitingFirstPrompt } from "./firstPrompt.js";
5
5
  import { heldReasonLabel } from "./heldLabel.js";
6
6
  import { harnessFor, paneClassifierFor } from "./harnesses.js";
7
7
  import { NEEDS_HUMAN_TAG } from "./portfolio.js";
8
+ import { pendingQuestionCount } from "./questions.js";
8
9
  import { loadProjectYml } from "./projectYml.js";
9
10
  import { parseTags } from "./result.js";
10
11
  import { ACTIVE_TIMER_WHERE } from "./scheduler.js";
@@ -20,6 +21,8 @@ export function crewStoreProblem(database = db) {
20
21
  const columns = database.prepare("SELECT name FROM pragma_table_info('agents')").all();
21
22
  if (!columns.some((c) => c.name === "todo_id"))
22
23
  return "the store predates the worker-to-todo link (agents.todo_id)";
24
+ if (!tables.has("questions"))
25
+ return "the store has no questions table";
23
26
  return null;
24
27
  }
25
28
  function isoUtc(value) {
@@ -235,12 +238,26 @@ function tokenize(segment) {
235
238
  return tokens;
236
239
  }
237
240
  const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
241
+ const SHELL_PREFIX_KEYWORDS = new Set(["if", "elif", "while", "until", "do", "then", "else", "!", "{"]);
242
+ const SHELL_HEADER_KEYWORDS = new Set(["for", "select", "case"]);
243
+ const SHELL_CLOSING_KEYWORDS = new Set(["done", "fi", "esac", "}"]);
238
244
  function toSegment(segment) {
239
245
  const tokens = tokenize(segment);
240
246
  let start = 0;
241
247
  while (start < tokens.length && ENV_ASSIGNMENT.test(tokens[start]))
242
248
  start++;
243
- const rest = tokens.slice(start);
249
+ let rest = tokens.slice(start);
250
+ while (rest.length > 0) {
251
+ if (SHELL_HEADER_KEYWORDS.has(rest[0]) || SHELL_CLOSING_KEYWORDS.has(rest[0])) {
252
+ rest = [];
253
+ break;
254
+ }
255
+ if (!SHELL_PREFIX_KEYWORDS.has(rest[0]))
256
+ break;
257
+ rest = rest.slice(1);
258
+ while (rest.length > 0 && ENV_ASSIGNMENT.test(rest[0]))
259
+ rest = rest.slice(1);
260
+ }
244
261
  return { tokens: rest[0] === "cd" ? [] : rest, writes: writesToFile(segment) };
245
262
  }
246
263
  function segmentsOf(command) {
@@ -272,7 +289,7 @@ function matchesPrefix(tokens, matcher) {
272
289
  return tokens.length >= matcher.tokens.length && matcher.tokens.every((re, i) => re.test(tokens[i]));
273
290
  }
274
291
  const READING_EXES = new Set(["rg", "grep", "cat", "head", "tail", "sed"]);
275
- const FALLBACK_SKIP = new Set(["printf", "echo", "true", ":"]);
292
+ const FALLBACK_SKIP = new Set(["printf", "echo", "true", ":", "[", "[[", "test"]);
276
293
  const NODE_RUNNERS = new Set(["npm", "pnpm", "yarn"]);
277
294
  const TEST_BINARIES = new Set(["pest", "phpunit", "vitest", "jest"]);
278
295
  const BUILD_BINARIES = new Set(["pint", "phpstan"]);
@@ -482,6 +499,7 @@ export function collectCrew(project, now) {
482
499
  read_at: now.toISOString(),
483
500
  lanes,
484
501
  needs_you: needsYou,
502
+ questions: { pending: pendingQuestionCount(project.id) },
485
503
  wakes: {
486
504
  pending: pending.length,
487
505
  next: pending[0]
package/dist/db.js CHANGED
@@ -430,6 +430,145 @@ 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
+ `,
433
572
  ];
434
573
  export function storeSchemaAhead(database) {
435
574
  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