@cmgmyr/hive 1.11.4 → 1.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ export const LEAD_KIND = "lead";
2
+ export const LEAD_ACTOR_PREFIX = `${LEAD_KIND}:`;
3
+ export const isLeadActorId = (actorId) => actorId.startsWith(LEAD_ACTOR_PREFIX);
4
+ export const mintLeadActorId = (agentId) => `${LEAD_ACTOR_PREFIX}${agentId}`;
5
+ export const LEAD_NAME = LEAD_KIND;
6
+ export const isReservedAgentName = (name) => name.toLowerCase() === LEAD_NAME;
@@ -0,0 +1,98 @@
1
+ import { db } from "./db.js";
2
+ import { LEAD_KIND } from "./agentKinds.js";
3
+ import { observationFailed, observeRowOwnership, ownershipLiveness, rowOwnership } from "./tmux.js";
4
+ export const CLOSED_ROW_ORDER = "(parked_at != '') DESC, closed_at DESC, id DESC";
5
+ export function getAgentRow(projectId, id, notFoundHint) {
6
+ const row = db.prepare("SELECT * FROM agents WHERE project_id = ? AND id = ?").get(projectId, id);
7
+ if (!row)
8
+ throw new Error(`No agent ${id} in project ${projectId}. ${notFoundHint}`);
9
+ return row;
10
+ }
11
+ function closedAgentNamed(projectId, needle) {
12
+ return db
13
+ .prepare(`SELECT id, name, kind, parked_at FROM agents WHERE project_id = ? AND status != 'running' ORDER BY ${CLOSED_ROW_ORDER}`)
14
+ .all(projectId).find((r) => r.name.toLowerCase() === needle);
15
+ }
16
+ export function findAgent(projectId, ref) {
17
+ if (ref.agent_id != null) {
18
+ return getAgentRow(projectId, ref.agent_id, "Call agent_list.");
19
+ }
20
+ if (ref.name) {
21
+ const rows = db
22
+ .prepare("SELECT * FROM agents WHERE project_id = ? AND status = 'running' ORDER BY id")
23
+ .all(projectId);
24
+ const exact = rows.filter((r) => r.name === ref.name);
25
+ if (exact.length === 1)
26
+ return exact[0];
27
+ if (exact.length > 1) {
28
+ throw new Error(`Multiple running agents named "${ref.name}". Target by agent_id instead.`);
29
+ }
30
+ const needle = ref.name.toLowerCase();
31
+ const sameName = rows.filter((r) => r.name.toLowerCase() === needle);
32
+ if (sameName.length === 1)
33
+ return sameName[0];
34
+ const closed = closedAgentNamed(projectId, needle);
35
+ if (closed) {
36
+ if (closed.parked_at) {
37
+ throw new Error(`Agent ${closed.id} ("${closed.name}") is PARKED, not finished - it was paused on ` +
38
+ `${closed.parked_at} and its session is waiting. Bring it back with agent_resume(agent_id: ` +
39
+ `${closed.id}), or abandon the park with agent_close(agent_id: ${closed.id}).`);
40
+ }
41
+ const remedy = closed.kind === LEAD_KIND ? "Run `hive lead` to start a new one" : "Spawn a new worker";
42
+ throw new Error(`Agent ${closed.id} ("${closed.name}") is closed. ${remedy}, or target a running one by name or agent_id.`);
43
+ }
44
+ const partial = rows.filter((r) => r.name.toLowerCase().includes(needle));
45
+ if (partial.length === 1)
46
+ return partial[0];
47
+ if (partial.length > 1) {
48
+ const candidates = partial.map((r) => `${r.name} (agent_id ${r.id})`).join(", ");
49
+ throw new Error(`"${ref.name}" matches ${partial.length} running agents: ${candidates}. Use the full name or agent_id.`);
50
+ }
51
+ if (/^\d+$/.test(ref.name)) {
52
+ const id = Number(ref.name);
53
+ if (Number.isSafeInteger(id)) {
54
+ const byId = db
55
+ .prepare("SELECT * FROM agents WHERE project_id = ? AND id = ? AND status = 'running'")
56
+ .get(projectId, id);
57
+ if (byId)
58
+ return byId;
59
+ }
60
+ }
61
+ else if (/^[a-z]+:\d+$/i.test(ref.name)) {
62
+ const byActorId = db
63
+ .prepare("SELECT * FROM agents WHERE project_id = ? AND actor_id = ? AND status = 'running'")
64
+ .get(projectId, ref.name);
65
+ if (byActorId)
66
+ return byActorId;
67
+ }
68
+ throw new Error(`No running agent matching "${ref.name}" in project ${projectId}. Call agent_list.`);
69
+ }
70
+ throw new Error("Pass agent_id or name.");
71
+ }
72
+ export const PROBE_FAILED_NOTE = "tmux could not be probed, so liveness is unknown. Nothing was changed. Retry in a few seconds.";
73
+ const unknownOwnershipNote = (snapshot) => observationFailed(snapshot) ? PROBE_FAILED_NOTE : UNVERIFIED_OWNERSHIP_NOTE;
74
+ export const UNVERIFIED_OWNERSHIP_NOTE = "This row's pane cannot be verified as its own: it is on another tmux socket, or no pane pid was recorded for it, so liveness is unknown and no screen was read. Nothing was changed. If the row is stale, a human or peer lead can retire it with agent_close(row_only=true).";
75
+ export const probeFailed = (agent) => new Error(`Agent ${agent.id} ("${agent.name}"): ${PROBE_FAILED_NOTE}`);
76
+ // A failed probe throws rather than answering unknown, so callers keep the retry wording.
77
+ export function observeOwnership(agent) {
78
+ const ownership = observeRowOwnership(agent);
79
+ if (ownership === "probe-failed")
80
+ throw probeFailed(agent);
81
+ return ownership;
82
+ }
83
+ export const REISSUED_PANE_NOTE = "Its recorded pane id now belongs to a different process (the recorded pane pid no longer matches), so its own process is gone and the pane is someone else's. Nothing was typed, read or killed there.";
84
+ export function notOwnedError(agent, ownership) {
85
+ const who = `Agent ${agent.id} ("${agent.name}")`;
86
+ if (ownership === "gone") {
87
+ return new Error(`${who} has no live tmux window (its process exited or the window was killed). Close it with agent_close and spawn a new one.`);
88
+ }
89
+ if (ownership === "reissued") {
90
+ return new Error(`${who}: pane ownership reads reissued. ${REISSUED_PANE_NOTE} Close the row with agent_close, which retires it without touching that pane.`);
91
+ }
92
+ return new Error(`${who}: pane ownership reads unknown. ${UNVERIFIED_OWNERSHIP_NOTE}`);
93
+ }
94
+ export function summaryLiveness(row, snapshot) {
95
+ if (row.status !== "running")
96
+ return false;
97
+ return ownershipLiveness(rowOwnership(row, snapshot));
98
+ }
package/dist/backup.js CHANGED
@@ -2,7 +2,7 @@ import SqliteDb from "better-sqlite3";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import { errorMessage } from "./result.js";
5
+ import { errorMessage } from "./errors.js";
6
6
  export const BACKUP_DIRNAME = "backups";
7
7
  export function backupsDir(dataDir) {
8
8
  return join(dataDir, BACKUP_DIRNAME);
@@ -1 +1 @@
1
- {"version":"1.11.4","sha":"be85c43","dirty":false,"build_id":"002d81642b9604f453a66605e3bc23656ad7b425069e016be8bfda87d6e532d1"}
1
+ {"version":"1.11.6","sha":"b4e9fb0","dirty":false,"build_id":"fb0debbc0e5e030650660c10505b25c26e60160403f384ce900920146e639961"}
package/dist/cli.js CHANGED
@@ -23,7 +23,8 @@ import { confirmQueenWrite, listQueenAudit } from "./queenAudit.js";
23
23
  import { formatRowCounts, projectRowCounts, removeProject } from "./projectRemove.js";
24
24
  import { ensureHooksFile, ensureLeadHooksFile } from "./hooks.js";
25
25
  import { errorMessage, registrationNoticeText, withTrailingNewline } from "./result.js";
26
- import { ACTIVE_TIMER_WHERE, socketAwaitingWhere, dashboardFileContained, describeStall, HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX, HELD_REASON_UNSUBMITTED_INPUT_PREFIX, janitor, resolveDashboardFile, generateQueenDashboardNow, transcriptStaleness, } from "./scheduler.js";
26
+ import { socketAwaitingWhere, dashboardFileContained, describeStall, HELD_REASON_UNCLASSIFIABLE_PANE_PREFIX, HELD_REASON_UNSUBMITTED_INPUT_PREFIX, janitor, resolveDashboardFile, generateQueenDashboardNow, transcriptStaleness, } from "./scheduler.js";
27
+ import { ACTIVE_TIMER_WHERE } from "./wakeSql.js";
27
28
  import { readTurnCount } from "./turnCount.js";
28
29
  import { leadSessionEnded } from "./leadState.js";
29
30
  import { heldReasonLabel } from "./heldLabel.js";
@@ -36,7 +37,7 @@ import { asNameClash, buildEnvFlags, closeAgentRow, isReservedAgentName, launchA
36
37
  import { adoptableWindow, applyLayout, claimInitialWindow, configureHiveWindow, controlModeFor, createWindow, DEFAULT_LAYOUT, describePaneChoice, ensureSession, findProjectWindow, foreignSocket, isViewSessionName, listOwnedWindows, liveTargets, observationFailed, observeRowOwnership, ORPHAN_MIN_AGE_MS, orphanScratchServers, orphansWorthWarningAbout, paneIsAloneInWindow, paneVisibility, panePidForRecord, PROCESSES_LAYOUT, processesPaneTitle, processesWindowName, projectWindows, RAW_ATTACH_TMUX_CONFIG, renderAttachCommand, resolveAttachTarget, resolveInTmuxTarget, rowLive, rowOwnership, SESSION_PREFIX, appendGlobalHook, globalHooks, replaceGlobalHooks, sessionName, setPaneTitle, shellQuote, shownPaneTitle, makeProcessesWindow, tmux, TMUX_DOC, tmuxSaysNothingThere, tmuxTimeoutOverride, tmuxSocketPath, untrustedTmuxServer, waitForPaneEstablished, windowLayout, } from "./tmux.js";
37
38
  import { collectCrew, crewStoreProblem } from "./crew.js";
38
39
  import { describeVisibility, formatLocal, processCounts } from "./dashboard.js";
39
- import { runningCommandRow, runningCommandRows, snapshotProcesses, stopAllProcesses, stopLine, stopProcess, STOP_REASONS, } from "./processes.js";
40
+ import { runningCommandRow, runningCommandRows, snapshotProcesses, stopAllProcesses, stopLine, stopProcess, renderStart, STOP_REASONS, } from "./processes.js";
40
41
  import { activeProfile, agentVarKeys, BUILT_IN_PROJECT_YML, configHash, loadProjectYml, mergedProjectVars, NO_PROFILE, PROJECT_YML_KEYS, resolveCommandDir, resolveFirstMessage, } from "./projectYml.js";
41
42
  import { appendClaudeLeadArgs, crewPluginDir, isTrusted, leadIdentityEnv, renderLeadPosture } from "./leadLaunch.js";
42
43
  import { handoffStatusSegment } from "./leadHandoff.js";
@@ -47,8 +48,9 @@ import { FIRST_MESSAGE_SHA_ENV, firstMessageDigest } from "./firstMessage.js";
47
48
  import { ageSecondsSince, deriveProvenance, describeForHuman, describeLastLogEvent, humanizeAge, lastLogEvent, lastPermissionMode, reportsAgentStateLog, } from "./stateProvenance.js";
48
49
  import { awaitingFirstPromptSql } from "./firstPrompt.js";
49
50
  import { checkoutRoot, createProfile, forkProfile, PROFILE_FILES, ProfileError, profileExists, profileFileNames, profileNames, profileStatus, readProfileFile, referencedPads, referencedPaths, renderProfileFile, resolveProfileFile, REWRITE_THRESHOLD, templateVars, userProfilesDir, } from "./profiles.js";
50
- import { getTodoDetail, listTodoSummaries, OPEN_BLOCKERS_SQL, TODO_STATUSES, } from "./tools/todos.js";
51
- import { createPad, getActivePadByName, listActivePads, overwritePadContent, } from "./tools/pads.js";
51
+ import { getTodoDetail, listTodoSummaries, } from "./todos.js";
52
+ import { ACTIVE_TODO_SQL, OPEN_BLOCKERS_SQL, TODO_STATUSES } from "./todoScope.js";
53
+ import { createPad, getActivePadByName, listActivePads, overwritePadContent, } from "./pads.js";
52
54
  import { answerQuestion, listQuestions } from "./questions.js";
53
55
  function usage() {
54
56
  console.log(`hive — run a crew of Claude Code and Codex workers from one lead session
@@ -213,9 +215,15 @@ async function ensureTrusted(projectId, name, command, dir, env) {
213
215
  db.prepare("INSERT OR IGNORE INTO command_trust (project_id, name, config_hash) VALUES (?, ?, ?)").run(projectId, name, hash);
214
216
  return true;
215
217
  }
216
- function startYmlCommand(project, name, proc, config) {
218
+ function startYmlCommand(project, name, proc, config, trusted) {
219
+ if (!trusted)
220
+ return { status: "refused", reason: "untrusted" };
217
221
  if (isReservedAgentName(name)) {
218
- return `skipped: "lead" is reserved for this project's lead session and cannot be used as a process name`;
222
+ return {
223
+ status: "refused",
224
+ reason: "reserved-name",
225
+ message: `skipped: "lead" is reserved for this project's lead session and cannot be used as a process name`,
226
+ };
219
227
  }
220
228
  const existing = db
221
229
  .prepare("SELECT id, tmux_target, tmux_socket FROM agents WHERE project_id = ? AND name = ? AND status = 'running'")
@@ -225,10 +233,15 @@ function startYmlCommand(project, name, proc, config) {
225
233
  if (live) {
226
234
  const windows = projectWindows(sessionName(), project.id);
227
235
  const where = windows === null ? null : paneVisibility(existing.tmux_target, windows);
228
- return where === "shown" || where === "hidden" ? `already running (${where})` : "already running";
236
+ return { status: "already-running", location: where === "shown" || where === "hidden" ? where : null };
237
+ }
238
+ if (live === null) {
239
+ return {
240
+ status: "refused",
241
+ reason: "unknown-liveness",
242
+ message: "skipped: tmux could not be probed, so hive cannot tell whether it is already running",
243
+ };
229
244
  }
230
- if (live === null)
231
- return "skipped: tmux could not be probed, so hive cannot tell whether it is already running";
232
245
  closeAgentRow(existing.id);
233
246
  }
234
247
  let dir;
@@ -236,7 +249,7 @@ function startYmlCommand(project, name, proc, config) {
236
249
  dir = resolveCommandDir(project.path, proc.dir);
237
250
  }
238
251
  catch (e) {
239
- return `skipped: ${errorMessage(e)}`;
252
+ return { status: "refused", reason: "escaped-dir", message: `skipped: ${errorMessage(e)}` };
240
253
  }
241
254
  const placement = proc.visible
242
255
  ? (config.placement ?? (process.env.HIVE_SPAWN_PLACEMENT === "window" ? "window" : "split"))
@@ -260,17 +273,20 @@ function startYmlCommand(project, name, proc, config) {
260
273
  setPaneTitle(target, shownPaneTitle(project.name, name));
261
274
  if (landedInProjectId != null) {
262
275
  const landedName = getProject(landedInProjectId)?.name ?? `project ${landedInProjectId}`;
263
- return `started, landed in project "${landedName}"'s window`;
276
+ return { status: "started", message: `started, landed in project "${landedName}"'s window` };
264
277
  }
265
- return "started";
278
+ return { status: "started", message: "started" };
266
279
  }
267
- return inProcessesWindow
268
- ? "started (hidden)"
269
- : "started in its own window: it opened this project's tmux session, and a session's first window " +
270
- `cannot be a tile. Move it with: hive hide "${name}"`;
280
+ return {
281
+ status: "started",
282
+ message: inProcessesWindow
283
+ ? "started (hidden)"
284
+ : "started in its own window: it opened this project's tmux session, and a session's first window " +
285
+ `cannot be a tile. Move it with: hive hide "${name}"`,
286
+ };
271
287
  }
272
288
  catch (e) {
273
- return `failed: ${errorMessage(e)}`;
289
+ return { status: "failed", message: errorMessage(e) };
274
290
  }
275
291
  }
276
292
  function attach(session, project, window) {
@@ -636,9 +652,10 @@ async function cmdLead(argv) {
636
652
  console.log(`- ${name}: defined, auto_start off (start with: hive start "${name}")`);
637
653
  continue;
638
654
  }
639
- if (await ensureTrusted(project.id, name, proc.command, proc.dir, proc.env)) {
640
- console.log(`- ${name}: ${startYmlCommand(project, name, proc, config)}`);
641
- }
655
+ const trusted = await ensureTrusted(project.id, name, proc.command, proc.dir, proc.env);
656
+ const result = startYmlCommand(project, name, proc, config, trusted);
657
+ if (trusted)
658
+ console.log(`- ${name}: ${renderStart(result)}`);
642
659
  }
643
660
  }
644
661
  if (registrationNotice) {
@@ -1366,9 +1383,12 @@ async function cmdStart(argv) {
1366
1383
  console.log(processNotDefined(config, name));
1367
1384
  process.exit(1);
1368
1385
  }
1369
- if (await ensureTrusted(project.id, name, proc.command, proc.dir, proc.env)) {
1370
- console.log(`${name}: ${startYmlCommand(project, name, proc, config)}`);
1371
- }
1386
+ const trusted = await ensureTrusted(project.id, name, proc.command, proc.dir, proc.env);
1387
+ const result = startYmlCommand(project, name, proc, config, trusted);
1388
+ if (trusted)
1389
+ console.log(`${name}: ${renderStart(result)}`);
1390
+ if (!trusted || result.status === "failed" || result.status === "refused")
1391
+ process.exitCode = 1;
1372
1392
  }
1373
1393
  function processNotDefined(config, name) {
1374
1394
  const known = Object.keys(config?.processes ?? {});
@@ -1631,7 +1651,7 @@ function cmdStatus() {
1631
1651
  .prepare("SELECT * FROM agents WHERE project_id = ? AND status = 'running' ORDER BY kind DESC, id")
1632
1652
  .all(project.id);
1633
1653
  const todos = db
1634
- .prepare("SELECT COUNT(*) AS n FROM todos WHERE project_id = ? AND status IN ('open', 'in_progress') AND archived_at IS NULL")
1654
+ .prepare(`SELECT COUNT(*) AS n FROM todos t WHERE t.project_id = ? AND ${ACTIVE_TODO_SQL}`)
1635
1655
  .get(project.id).n;
1636
1656
  const { wakes, heldWakes } = db
1637
1657
  .prepare(`SELECT COUNT(*) AS wakes, COUNT(held_at) AS heldWakes FROM wakes WHERE project_id = ? AND ${ACTIVE_TIMER_WHERE}`)
@@ -2769,9 +2789,9 @@ function cmdStatusline() {
2769
2789
  const count = (sql) => db.prepare(sql).get(project.id).n;
2770
2790
  const agents = count("SELECT COUNT(*) AS n FROM agents WHERE project_id = ? AND status = 'running' AND kind = 'agent'");
2771
2791
  const commands = count("SELECT COUNT(*) AS n FROM agents WHERE project_id = ? AND status = 'running' AND kind = 'command'");
2772
- const todos = count("SELECT COUNT(*) AS n FROM todos WHERE project_id = ? AND status IN ('open', 'in_progress') AND archived_at IS NULL");
2792
+ const todos = count(`SELECT COUNT(*) AS n FROM todos t WHERE t.project_id = ? AND ${ACTIVE_TODO_SQL}`);
2773
2793
  const ready = count(`SELECT COUNT(*) AS n FROM todos t
2774
- WHERE t.project_id = ? AND t.status IN ('open', 'in_progress') AND t.archived_at IS NULL
2794
+ WHERE t.project_id = ? AND ${ACTIVE_TODO_SQL}
2775
2795
  AND NOT EXISTS (${OPEN_BLOCKERS_SQL})`);
2776
2796
  const pads = count("SELECT COUNT(*) AS n FROM pads WHERE project_id = ? AND archived = 0");
2777
2797
  const wakes = count(`SELECT COUNT(*) AS n FROM wakes WHERE project_id = ? AND ${ACTIVE_TIMER_WHERE}`);
package/dist/codexHome.js CHANGED
@@ -303,7 +303,6 @@ export function ensureCodexHome(input) {
303
303
  indexJs,
304
304
  serverEnv: { ...input.serverEnv, HIVE_AGENT_ID: input.actorId },
305
305
  statusLine,
306
- readOnly: input.readOnly,
307
306
  modelContextWindow,
308
307
  modelAutoCompactTokenLimit,
309
308
  }));
package/dist/context.js CHANGED
@@ -1,12 +1,12 @@
1
- import { execFileSync } from "node:child_process";
2
1
  import { lstatSync, realpathSync } from "node:fs";
3
2
  import { userInfo } from "node:os";
4
3
  import { basename, dirname, join, resolve, sep } from "node:path";
5
4
  import { storeDir } from "./dataDir.js";
6
5
  import { db } from "./db.js";
7
- import { LEAD_KIND } from "./spawn.js";
6
+ import { LEAD_KIND } from "./agentKinds.js";
8
7
  import { rowOwnership } from "./tmux.js";
9
- import { recordQueenRefusal } from "./queenAudit.js";
8
+ import { recordQueenRefusal } from "./queenAuditStore.js";
9
+ import { gitOut } from "./git.js";
10
10
  let selectedId = null;
11
11
  let cachedActorId = null;
12
12
  let lastTouchMs = 0;
@@ -92,18 +92,13 @@ export function registeredAncestor(dir) {
92
92
  return bestPrefixMatch(projects, dir);
93
93
  }
94
94
  export function gitPrimaryRoot(dir) {
95
+ const commonDir = gitOut(dir, ["rev-parse", "--git-common-dir"]);
96
+ if (commonDir === null)
97
+ return null;
98
+ const resolvedCommonDir = resolve(dir, commonDir);
99
+ if (basename(resolvedCommonDir) !== ".git")
100
+ return null;
95
101
  try {
96
- const { GIT_DIR: _gitDir, GIT_COMMON_DIR: _gitCommonDir, GIT_WORK_TREE: _gitWorkTree, ...env } = process.env;
97
- const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
98
- cwd: dir,
99
- encoding: "utf8",
100
- stdio: ["ignore", "pipe", "ignore"],
101
- timeout: 2000,
102
- env,
103
- }).trim();
104
- const resolvedCommonDir = resolve(dir, commonDir);
105
- if (basename(resolvedCommonDir) !== ".git")
106
- return null;
107
102
  return realpathSync(dirname(resolvedCommonDir));
108
103
  }
109
104
  catch {
@@ -111,19 +106,13 @@ export function gitPrimaryRoot(dir) {
111
106
  }
112
107
  }
113
108
  function gitDirs(dir) {
109
+ const result = gitOut(dir, ["rev-parse", "--git-dir", "--git-common-dir"]);
110
+ if (result === null)
111
+ return null;
112
+ const out = result.split("\n");
113
+ if (out.length !== 2)
114
+ return null;
114
115
  try {
115
- const { GIT_DIR: _gitDir, GIT_COMMON_DIR: _gitCommonDir, GIT_WORK_TREE: _gitWorkTree, ...env } = process.env;
116
- const out = execFileSync("git", ["rev-parse", "--git-dir", "--git-common-dir"], {
117
- cwd: dir,
118
- encoding: "utf8",
119
- stdio: ["ignore", "pipe", "ignore"],
120
- timeout: 2000,
121
- env,
122
- })
123
- .trim()
124
- .split("\n");
125
- if (out.length !== 2)
126
- return null;
127
116
  return {
128
117
  gitDir: realpathSync(resolve(dir, out[0])),
129
118
  commonDir: realpathSync(resolve(dir, out[1])),
package/dist/crew.js CHANGED
@@ -1,15 +1,16 @@
1
- import { execFileSync } from "node:child_process";
2
1
  import { db } from "./db.js";
3
2
  import { isLinkedWorktree } from "./context.js";
4
3
  import { awaitingFirstPrompt } from "./firstPrompt.js";
5
4
  import { heldReasonLabel } from "./heldLabel.js";
6
5
  import { harnessFor, paneClassifierFor } from "./harnesses.js";
7
6
  import { NEEDS_HUMAN_TAG } from "./portfolio.js";
7
+ import { ACTIVE_TODO_SQL } from "./todoScope.js";
8
8
  import { pendingQuestionCount } from "./questions.js";
9
9
  import { loadProjectYml } from "./projectYml.js";
10
10
  import { parseTags } from "./result.js";
11
- import { ACTIVE_TIMER_WHERE } from "./scheduler.js";
11
+ import { ACTIVE_TIMER_WHERE } from "./wakeSql.js";
12
12
  import { reportsAgentStateLog } from "./stateProvenance.js";
13
+ import { gitOut } from "./git.js";
13
14
  import { liveTargets, rowOwnership } from "./tmux.js";
14
15
  import { SKILL_TOOL, lastComponent, readContextFill, readRecentToolCalls, } from "./transcript.js";
15
16
  export function crewStoreProblem(database = db) {
@@ -50,36 +51,18 @@ function oneShotWatched(pending) {
50
51
  }
51
52
  });
52
53
  }
53
- function stripped(env) {
54
- const { GIT_DIR: _gitDir, GIT_COMMON_DIR: _gitCommonDir, GIT_WORK_TREE: _gitWorkTree, ...rest } = env;
55
- return rest;
56
- }
57
- function git(cwd, args) {
58
- try {
59
- return execFileSync("git", args, {
60
- cwd,
61
- encoding: "utf8",
62
- stdio: ["ignore", "pipe", "ignore"],
63
- timeout: 2000,
64
- env: stripped(process.env),
65
- }).trim();
66
- }
67
- catch {
68
- return null;
69
- }
70
- }
71
54
  export function readCommitsAhead(cwd, projectRoot) {
72
55
  if (!cwd || cwd === projectRoot || !isLinkedWorktree(cwd))
73
56
  return null;
74
57
  const candidates = [];
75
- const remoteHead = git(cwd, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]);
58
+ const remoteHead = gitOut(cwd, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]);
76
59
  if (remoteHead)
77
60
  candidates.push(remoteHead);
78
61
  candidates.push("refs/heads/main", "refs/heads/master");
79
62
  for (const ref of candidates) {
80
- if (git(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]) === null)
63
+ if (gitOut(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]) === null)
81
64
  continue;
82
- const count = git(cwd, ["rev-list", "--count", `${ref}..HEAD`]);
65
+ const count = gitOut(cwd, ["rev-list", "--count", `${ref}..HEAD`]);
83
66
  return count !== null && /^\d+$/.test(count) ? Number(count) : null;
84
67
  }
85
68
  return null;
@@ -481,7 +464,7 @@ export function collectCrew(project, now) {
481
464
  if (!staffed.has(todo.id))
482
465
  lanes.push({ todo, worker: null });
483
466
  const needsYou = db
484
- .prepare("SELECT id, slug, tags FROM todos WHERE project_id = ? AND status != 'completed' AND archived_at IS NULL ORDER BY id")
467
+ .prepare(`SELECT t.id, t.slug, t.tags FROM todos t WHERE t.project_id = ? AND ${ACTIVE_TODO_SQL} ORDER BY t.id`)
485
468
  .all(project.id)
486
469
  .filter((t) => parseTags(t.tags).includes(NEEDS_HUMAN_TAG))
487
470
  .map((t) => ({ id: t.id, slug: t.slug }));
package/dist/dashboard.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
- import { createRequire } from "node:module";
2
+ import { listQuestions, pendingQuestionCount } from "./questions.js";
3
+ import { ACTIVE_TIMER_WHERE } from "./wakeSql.js";
3
4
  import { db } from "./db.js";
5
+ import { ACTIVE_TODO_SQL, NOT_COMPLETED_TODO_SQL, OPEN_BLOCKERS_SQL } from "./todoScope.js";
4
6
  import { getProject } from "./context.js";
5
7
  export const describeVisibility = (visibility) => visibility === null ? "running" : visibility === "window" ? "own window" : visibility;
6
8
  export function processCounts(procs) {
@@ -13,7 +15,6 @@ export function processCounts(procs) {
13
15
  }
14
16
  import { awaitingFirstPrompt } from "./firstPrompt.js";
15
17
  import { SLUG_MAX_LEN, cutToUnitBudget } from "./slug.js";
16
- const requireFromHere = createRequire(import.meta.url);
17
18
  export const TODO_CAP = 100;
18
19
  export const AGENT_CAP = 50;
19
20
  export const WAKE_CAP = 50;
@@ -86,7 +87,6 @@ function fetchProject(projectId) {
86
87
  throw new Error(`No project ${projectId}.`);
87
88
  return row;
88
89
  }
89
- const PENDING_WAKE_WHERE = "cancelled_at IS NULL AND (fired_at IS NULL OR repeat_every_ms IS NOT NULL)";
90
90
  function agentStatusLevel(state) {
91
91
  if (state === "working")
92
92
  return "live";
@@ -120,19 +120,16 @@ function fetchNowAgents(projectId) {
120
120
  function fetchNextWake(projectId) {
121
121
  return db
122
122
  .prepare(`SELECT kind, due_at, max_wait_at, deliver_actor FROM wakes
123
- WHERE project_id = ? AND ${PENDING_WAKE_WHERE}
123
+ WHERE project_id = ? AND ${ACTIVE_TIMER_WHERE}
124
124
  ORDER BY COALESCE(due_at, max_wait_at, created_at) LIMIT 1`)
125
125
  .get(projectId);
126
126
  }
127
127
  function fetchTodoCounts(projectId) {
128
128
  return db
129
129
  .prepare(`SELECT
130
- (SELECT COUNT(*) FROM todos WHERE project_id = ? AND archived_at IS NULL AND status != 'completed') AS open,
131
- (SELECT COUNT(*) FROM todos t WHERE t.project_id = ? AND t.archived_at IS NULL AND t.status != 'completed'
132
- AND EXISTS (
133
- SELECT 1 FROM todo_blockers b JOIN todos bt ON bt.id = b.blocker_id
134
- WHERE b.todo_id = t.id AND bt.status != 'completed'
135
- )) AS blocked,
130
+ (SELECT COUNT(*) FROM todos t WHERE t.project_id = ? AND ${ACTIVE_TODO_SQL}) AS open,
131
+ (SELECT COUNT(*) FROM todos t WHERE t.project_id = ? AND ${ACTIVE_TODO_SQL}
132
+ AND EXISTS (${OPEN_BLOCKERS_SQL})) AS blocked,
136
133
  (SELECT COUNT(*) FROM todos
137
134
  WHERE project_id = ? AND completed_at IS NOT NULL
138
135
  AND date(completed_at, 'localtime') >= date('now', 'localtime', '-6 days')) AS completed7d`)
@@ -262,10 +259,9 @@ function renderBoardSection(index, projectId) {
262
259
  }
263
260
  const OPEN_TODOS_SQL = `
264
261
  SELECT t.id, t.title, t.body, t.slug, t.status, t.priority,
265
- (SELECT COUNT(*) FROM todo_blockers b JOIN todos bt ON bt.id = b.blocker_id
266
- WHERE b.todo_id = t.id AND bt.status != 'completed') AS open_blockers
262
+ EXISTS (${OPEN_BLOCKERS_SQL}) AS blocked
267
263
  FROM todos t
268
- WHERE t.project_id = ? AND t.archived_at IS NULL AND t.status != 'completed'
264
+ WHERE t.project_id = ? AND ${ACTIVE_TODO_SQL}
269
265
  ORDER BY CASE t.priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, t.id`;
270
266
  function fetchBlockersFor(todoIds) {
271
267
  const byTodo = new Map();
@@ -274,7 +270,7 @@ function fetchBlockersFor(todoIds) {
274
270
  const placeholders = todoIds.map(() => "?").join(",");
275
271
  const rows = db
276
272
  .prepare(`SELECT b.todo_id, t.id, t.title FROM todo_blockers b JOIN todos t ON t.id = b.blocker_id
277
- WHERE b.todo_id IN (${placeholders}) AND t.status != 'completed'`)
273
+ WHERE b.todo_id IN (${placeholders}) AND t.${NOT_COMPLETED_TODO_SQL}`)
278
274
  .all(...todoIds);
279
275
  for (const row of rows) {
280
276
  const list = byTodo.get(row.todo_id) ?? [];
@@ -291,11 +287,11 @@ function todoBadgeLevel(status, blocked) {
291
287
  function renderTodosSection(index, projectId) {
292
288
  const all = db.prepare(OPEN_TODOS_SQL).all(projectId);
293
289
  const shown = all.slice(0, TODO_CAP);
294
- const blockersByTodo = fetchBlockersFor(shown.filter((t) => t.open_blockers > 0).map((t) => t.id));
295
- const blockedCount = all.filter((t) => t.open_blockers > 0).length;
290
+ const blockersByTodo = fetchBlockersFor(shown.filter((t) => t.blocked).map((t) => t.id));
291
+ const blockedCount = all.filter((t) => t.blocked).length;
296
292
  const rows = shown
297
293
  .map((t) => {
298
- const blocked = t.open_blockers > 0;
294
+ const blocked = Boolean(t.blocked);
299
295
  const blockers = blocked
300
296
  ? (blockersByTodo.get(t.id) ?? []).map((b) => `${mono(`#${b.id}`)} ${prose(b.title)}`).join(", ")
301
297
  : "";
@@ -324,7 +320,7 @@ function renderTodosSection(index, projectId) {
324
320
  `<p class="filter-none" id="todo-filter-none" hidden>No open todo matches that filter.</p>`
325
321
  : "";
326
322
  const body = filter +
327
- renderCappedList(shown.length, all.length, "open todos", "todos", rows, emptyState("No open todos.", "Every todo in this project is completed or archived. New ones land here."), "todo-list");
323
+ renderCappedList(shown.length, all.length, "open todos", "todos", rows, emptyState("No open todos.", "No active todos are listed. New open or in-progress todos land here. Backlog todos stay off this list."), "todo-list");
328
324
  return section(index, "todos", "Todos", `${all.length} open, ${blockedCount} blocked`, "checklist", String(shown.length), body);
329
325
  }
330
326
  export const CHART_DAYS = 7;
@@ -431,7 +427,7 @@ function renderThroughputSection(index, stats) {
431
427
  function renderWakesSection(index, projectId) {
432
428
  const all = db
433
429
  .prepare(`SELECT id, kind, body, owner, deliver_actor, due_at, max_wait_at, fire_count, repeat_every_ms, held_at
434
- FROM wakes WHERE project_id = ? AND ${PENDING_WAKE_WHERE}
430
+ FROM wakes WHERE project_id = ? AND ${ACTIVE_TIMER_WHERE}
435
431
  ORDER BY COALESCE(due_at, max_wait_at, created_at)`)
436
432
  .all(projectId);
437
433
  const shown = all.slice(0, WAKE_CAP);
@@ -465,8 +461,6 @@ function questionAge(createdAt) {
465
461
  return "now";
466
462
  }
467
463
  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
464
  const questions = listQuestions(projectId);
471
465
  if (questions.length === 0)
472
466
  return "";
package/dist/db.js CHANGED
@@ -4,7 +4,7 @@ import { join } from "node:path";
4
4
  import { guardAbi } from "./abi.js";
5
5
  import { guardStoreDir } from "./dataDir.js";
6
6
  import { maybeBackupBeforeMigrations } from "./backup.js";
7
- import { errorMessage } from "./result.js";
7
+ import { errorMessage } from "./errors.js";
8
8
  export const dataDir = guardStoreDir();
9
9
  mkdirSync(dataDir, { recursive: true });
10
10
  function sleepSync(ms) {
@@ -576,6 +576,42 @@ ALTER TABLE queen_audit ADD COLUMN problem TEXT NOT NULL DEFAULT '';
576
576
  `,
577
577
  `
578
578
  ALTER TABLE agents ADD COLUMN pane_started_at TEXT;
579
+ `,
580
+ `
581
+ DROP TRIGGER fence_handoff_held_claim;
582
+ CREATE TRIGGER fence_handoff_held_claim
583
+ BEFORE UPDATE OF fire_count, delivery_method ON wakes
584
+ FOR EACH ROW
585
+ WHEN (NEW.fire_count > OLD.fire_count
586
+ OR (NEW.delivery_method IS 'pty-after-socket-timeout'
587
+ AND OLD.delivery_method IS NOT 'pty-after-socket-timeout'
588
+ AND NEW.typed_at IS NULL))
589
+ AND EXISTS (
590
+ SELECT 1 FROM lead_handoffs h
591
+ WHERE h.lead_agent_id = (SELECT a.id FROM agents a
592
+ WHERE a.actor_id = NEW.deliver_actor
593
+ ORDER BY (a.status = 'running') DESC,
594
+ a.id DESC
595
+ LIMIT 1)
596
+ AND h.state NOT IN ('completed', 'failed')
597
+ AND h.request_wake_id IS NOT NEW.id
598
+ AND (
599
+ (h.state = 'grace'
600
+ AND h.hold_released_at IS NULL
601
+ AND h.grace_started_at IS NOT NULL
602
+ AND (julianday('now') - julianday(h.grace_started_at)) * 86400 < 900)
603
+ OR ((h.state IN ('grace', 'started')
604
+ OR (h.hold_released_at IS NULL AND (
605
+ h.state IN ('respawning', 'ambiguous', 'wind_down')
606
+ OR (h.state = 'requested'
607
+ AND (h.reason = 'stop' OR h.pass >= 2))
608
+ OR (h.state = 'postponed' AND h.reason = 'stop'))))
609
+ AND h.state <> 'grace'
610
+ AND (julianday('now') - julianday(h.updated_at)) * 86400 < 3600)
611
+ ))
612
+ BEGIN
613
+ SELECT RAISE(IGNORE);
614
+ END;
579
615
  `,
580
616
  ];
581
617
  export function storeSchemaAhead(database) {
package/dist/errors.js ADDED
@@ -0,0 +1,3 @@
1
+ export function errorMessage(e) {
2
+ return e instanceof Error ? e.message : String(e);
3
+ }
package/dist/git.js ADDED
@@ -0,0 +1,16 @@
1
+ import { execFileSync } from "node:child_process";
2
+ export function gitOut(cwd, args) {
3
+ const { GIT_DIR: _gitDir, GIT_COMMON_DIR: _gitCommonDir, GIT_WORK_TREE: _gitWorkTree, ...env } = process.env;
4
+ try {
5
+ return execFileSync("git", args, {
6
+ cwd,
7
+ encoding: "utf8",
8
+ stdio: ["ignore", "pipe", "ignore"],
9
+ timeout: 2000,
10
+ env,
11
+ }).trim();
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
@@ -3,7 +3,7 @@ import { chmodSync, closeSync, lstatSync, mkdirSync, openSync, readFileSync, ren
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import { isMap, parse, parseDocument } from "yaml";
5
5
  import { storeDir } from "./dataDir.js";
6
- import { errorMessage } from "./result.js";
6
+ import { errorMessage } from "./errors.js";
7
7
  export const ATTACH_MODES = ["auto", "raw", "control"];
8
8
  export const AUTO_ATTACH_MODES = ["auto", "on", "off"];
9
9
  export function isAttachMode(value) {
package/dist/harnesses.js CHANGED
@@ -53,7 +53,6 @@ const claudeHarness = {
53
53
  supportsRename: true,
54
54
  classifiesPaneScreen: true,
55
55
  paneClassifier: { choiceCheck: paneChoiceCheck, inputBoxState, inputBoxSnapshot, hasInputBox: paneHasInputBox, trailerView: claudeTrailerView },
56
- hasScopes: true,
57
56
  needsHome: false,
58
57
  // "--" so a message that starts with a dash is never read as a flag.
59
58
  initialPromptArgs: (message) => ["--", message],
@@ -82,7 +81,6 @@ export const codexHarness = {
82
81
  hasInputBox: codexPaneHasInputBox,
83
82
  trailerView: codexTrailerView,
84
83
  },
85
- hasScopes: false,
86
84
  needsHome: true,
87
85
  // Auto-submits with no Enter; verified live on v0.149.0.
88
86
  initialPromptArgs: (message) => ["--", message],
@@ -106,7 +104,6 @@ const unknownHarness = {
106
104
  supportsRename: false,
107
105
  classifiesPaneScreen: false,
108
106
  paneClassifier: null,
109
- hasScopes: false,
110
107
  needsHome: false,
111
108
  initialPromptArgs: null,
112
109
  // An unrecognized command's vocabulary is unknown, so it stops nothing rather than guessing.