@cmgmyr/hive 1.2.2 → 1.4.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.
- package/README.md +6 -1
- package/claude-plugin/README.md +4 -1
- package/claude-plugin/kickoff.mjs +2 -1
- package/claude-plugin/skills/profile/SKILL.md +78 -0
- package/claude-plugin/skills/profile/references/interview.md +139 -0
- package/claude-plugin/skills/profile/references/recipes/index.md +12 -0
- package/claude-plugin/skills/profile/references/recipes/review-decision.md +41 -0
- package/claude-plugin/skills/profile/references/recipes/session-continuity.md +37 -0
- package/claude-plugin/skills/profile/references/recipes/shared-test-resource.md +45 -0
- package/claude-plugin/skills/profile/references/recipes/verification.md +50 -0
- package/claude-plugin/skills/profile/references/recipes/worker-context-checkpoint.md +51 -0
- package/dist/brief.js +1 -1
- package/dist/build-info.json +1 -1
- package/dist/cli.js +436 -112
- package/dist/codexHome.js +23 -8
- package/dist/config.js +8 -61
- package/dist/context.js +191 -2
- package/dist/db.js +80 -20
- package/dist/firstMessage.js +5 -0
- package/dist/globalConfig.js +247 -0
- package/dist/harnesses.js +14 -4
- package/dist/help.js +29 -11
- package/dist/hook.js +9 -0
- package/dist/hooks.js +2 -1
- package/dist/index.js +11 -5
- package/dist/kickoff.js +42 -29
- package/dist/leadState.js +63 -0
- package/dist/leadWatch.js +73 -0
- package/dist/portfolio.js +210 -0
- package/dist/projectYml.js +242 -178
- package/dist/queenAudit.js +118 -0
- package/dist/queenDashboard.js +573 -0
- package/dist/scheduler.js +190 -20
- package/dist/spawn.js +17 -4
- package/dist/strictInput.js +24 -7
- package/dist/tmux.js +65 -0
- package/dist/tools/agents.js +153 -15
- package/dist/tools/kv.js +24 -0
- package/dist/tools/leases.js +14 -2
- package/dist/tools/meta.js +65 -3
- package/dist/tools/pads.js +42 -0
- package/dist/tools/queenAudit.js +13 -0
- package/dist/tools/todos.js +72 -13
- package/dist/tools/wakes.js +233 -65
- package/package.json +4 -4
- package/profiles/orchestration/worker.md +1 -1
- package/profiles/queen/posture.md +31 -0
- package/profiles/queen/runbook.md +26 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { listQueenAudit } from "../queenAudit.js";
|
|
2
|
+
import { run } from "../result.js";
|
|
3
|
+
import { idParam, limitParam } from "./params.js";
|
|
4
|
+
export function registerQueenAudit(server) {
|
|
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.",
|
|
7
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
8
|
+
inputSchema: {
|
|
9
|
+
project_id: idParam.optional().describe("Filter by the target project id. Only the queen may name another project."),
|
|
10
|
+
limit: limitParam,
|
|
11
|
+
},
|
|
12
|
+
}, (args) => run(() => listQueenAudit(args)));
|
|
13
|
+
}
|
package/dist/tools/todos.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { db } from "../db.js";
|
|
3
|
+
import { commitQueenStateWrite } from "../queenAudit.js";
|
|
3
4
|
import { currentActor, effectiveProjectId, resolveProject } from "../context.js";
|
|
4
5
|
import { matchesAnyTag, parseTags, run } from "../result.js";
|
|
5
6
|
import { findUnsafeControlChar } from "../tmux.js";
|
|
@@ -201,6 +202,12 @@ const completeTodo = db.transaction((projectId, todoId, completed) => {
|
|
|
201
202
|
export function registerTodos(server) {
|
|
202
203
|
server.registerTool("todo_create", {
|
|
203
204
|
description: "Create a project-scoped todo. Pass a short slug to have it render beside the id wherever this todo is listed; left unset, those surfaces fall back to the title instead (truncated in the dashboard). Optionally pass blocked_by todo ids to encode ordering. Returns a slim receipt.",
|
|
205
|
+
annotations: {
|
|
206
|
+
readOnlyHint: false,
|
|
207
|
+
destructiveHint: false,
|
|
208
|
+
idempotentHint: false,
|
|
209
|
+
openWorldHint: false,
|
|
210
|
+
},
|
|
204
211
|
inputSchema: {
|
|
205
212
|
title: z.string(),
|
|
206
213
|
body: z.string().optional().describe("Objective, owned files, acceptance criteria."),
|
|
@@ -214,17 +221,25 @@ export function registerTodos(server) {
|
|
|
214
221
|
}, (args) => run(() => {
|
|
215
222
|
const projectId = effectiveProjectId(args.project_id);
|
|
216
223
|
currentActor();
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
224
|
+
return commitQueenStateWrite("todo_create", projectId, args, () => {
|
|
225
|
+
const info = db
|
|
226
|
+
.prepare("INSERT INTO todos (project_id, title, body, priority, tags, slug) VALUES (?, ?, ?, ?, ?, ?)")
|
|
227
|
+
.run(projectId, args.title, args.body ?? "", args.priority ?? "medium", JSON.stringify(args.tags ?? []), args.slug ?? "");
|
|
228
|
+
const todoId = Number(info.lastInsertRowid);
|
|
229
|
+
for (const blockerId of args.blocked_by ?? []) {
|
|
230
|
+
addBlocker.immediate(projectId, todoId, blockerId);
|
|
231
|
+
}
|
|
232
|
+
return { project_id: projectId, todo_id: todoId };
|
|
233
|
+
});
|
|
225
234
|
}));
|
|
226
235
|
server.registerTool("todo_list", {
|
|
227
236
|
description: "List todo summaries. is_blocked=false finds dispatchable work. query matches title, body, and slug. Archived todos are excluded by default; include_archived=true retrieves them too.",
|
|
237
|
+
annotations: {
|
|
238
|
+
readOnlyHint: false,
|
|
239
|
+
destructiveHint: false,
|
|
240
|
+
idempotentHint: true,
|
|
241
|
+
openWorldHint: false,
|
|
242
|
+
},
|
|
228
243
|
inputSchema: {
|
|
229
244
|
status: statusParam.optional(),
|
|
230
245
|
is_blocked: z.boolean().optional(),
|
|
@@ -256,6 +271,12 @@ export function registerTodos(server) {
|
|
|
256
271
|
}));
|
|
257
272
|
server.registerTool("todo_get", {
|
|
258
273
|
description: "Read one todo in full: body, blockers, what it blocks, and optionally comments.",
|
|
274
|
+
annotations: {
|
|
275
|
+
readOnlyHint: false,
|
|
276
|
+
destructiveHint: false,
|
|
277
|
+
idempotentHint: true,
|
|
278
|
+
openWorldHint: false,
|
|
279
|
+
},
|
|
259
280
|
inputSchema: {
|
|
260
281
|
todo_id: idParam,
|
|
261
282
|
include_comments: z.boolean().optional(),
|
|
@@ -267,6 +288,12 @@ export function registerTodos(server) {
|
|
|
267
288
|
}));
|
|
268
289
|
server.registerTool("todo_update", {
|
|
269
290
|
description: "Update todo fields. Omitted fields are preserved. Returns a slim receipt.",
|
|
291
|
+
annotations: {
|
|
292
|
+
readOnlyHint: false,
|
|
293
|
+
destructiveHint: true,
|
|
294
|
+
idempotentHint: false,
|
|
295
|
+
openWorldHint: false,
|
|
296
|
+
},
|
|
270
297
|
inputSchema: {
|
|
271
298
|
todo_id: idParam,
|
|
272
299
|
title: z.string().optional(),
|
|
@@ -292,6 +319,12 @@ export function registerTodos(server) {
|
|
|
292
319
|
}));
|
|
293
320
|
server.registerTool("todo_archive", {
|
|
294
321
|
description: "Archive a todo (or unarchive with archived=false), mirroring pad_archive. Archived todos are excluded from todo_list by default; todo_get always reaches them by id. Refuses when this todo still blocks another todo that is not completed.",
|
|
322
|
+
annotations: {
|
|
323
|
+
readOnlyHint: false,
|
|
324
|
+
destructiveHint: true,
|
|
325
|
+
idempotentHint: false,
|
|
326
|
+
openWorldHint: false,
|
|
327
|
+
},
|
|
295
328
|
inputSchema: {
|
|
296
329
|
todo_id: idParam,
|
|
297
330
|
archived: z.boolean().optional().describe("Default true. Pass false to unarchive."),
|
|
@@ -305,6 +338,12 @@ export function registerTodos(server) {
|
|
|
305
338
|
}));
|
|
306
339
|
server.registerTool("todo_complete", {
|
|
307
340
|
description: "Mark a todo complete (or reopen with completed=false). Returns todo ids that this completion newly unblocked.",
|
|
341
|
+
annotations: {
|
|
342
|
+
readOnlyHint: false,
|
|
343
|
+
destructiveHint: true,
|
|
344
|
+
idempotentHint: false,
|
|
345
|
+
openWorldHint: false,
|
|
346
|
+
},
|
|
308
347
|
inputSchema: {
|
|
309
348
|
todo_id: idParam,
|
|
310
349
|
completed: z.boolean().optional().describe("Defaults to true."),
|
|
@@ -323,6 +362,12 @@ export function registerTodos(server) {
|
|
|
323
362
|
}));
|
|
324
363
|
server.registerTool("todo_comment", {
|
|
325
364
|
description: "Add a comment to a todo. Use for handoffs: changed files, tests run, decisions, remaining risk.",
|
|
365
|
+
annotations: {
|
|
366
|
+
readOnlyHint: false,
|
|
367
|
+
destructiveHint: false,
|
|
368
|
+
idempotentHint: false,
|
|
369
|
+
openWorldHint: false,
|
|
370
|
+
},
|
|
326
371
|
inputSchema: {
|
|
327
372
|
todo_id: idParam,
|
|
328
373
|
body: z.string(),
|
|
@@ -332,14 +377,22 @@ export function registerTodos(server) {
|
|
|
332
377
|
}, (args) => run(() => {
|
|
333
378
|
const projectId = effectiveProjectId(args.project_id);
|
|
334
379
|
const todo = getTodo(projectId, args.todo_id);
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
380
|
+
return commitQueenStateWrite("todo_comment", projectId, args, () => {
|
|
381
|
+
const info = db
|
|
382
|
+
.prepare("INSERT INTO todo_comments (todo_id, author, body) VALUES (?, ?, ?)")
|
|
383
|
+
.run(todo.id, currentActor(), args.body);
|
|
384
|
+
touch(todo.id);
|
|
385
|
+
return { project_id: projectId, todo_id: todo.id, comment_id: Number(info.lastInsertRowid) };
|
|
386
|
+
});
|
|
340
387
|
}));
|
|
341
388
|
server.registerTool("todo_block", {
|
|
342
389
|
description: "Add a blocker: todo_id cannot start until blocker_id completes. Cycles are rejected.",
|
|
390
|
+
annotations: {
|
|
391
|
+
readOnlyHint: false,
|
|
392
|
+
destructiveHint: false,
|
|
393
|
+
idempotentHint: false,
|
|
394
|
+
openWorldHint: false,
|
|
395
|
+
},
|
|
343
396
|
inputSchema: {
|
|
344
397
|
todo_id: idParam,
|
|
345
398
|
blocker_id: idParam,
|
|
@@ -354,6 +407,12 @@ export function registerTodos(server) {
|
|
|
354
407
|
}));
|
|
355
408
|
server.registerTool("todo_unblock", {
|
|
356
409
|
description: "Remove one blocker relationship from a todo.",
|
|
410
|
+
annotations: {
|
|
411
|
+
readOnlyHint: false,
|
|
412
|
+
destructiveHint: true,
|
|
413
|
+
idempotentHint: false,
|
|
414
|
+
openWorldHint: false,
|
|
415
|
+
},
|
|
357
416
|
inputSchema: {
|
|
358
417
|
todo_id: idParam,
|
|
359
418
|
blocker_id: idParam,
|
package/dist/tools/wakes.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { db } from "../db.js";
|
|
3
|
-
import {
|
|
3
|
+
import { commitQueenStateWrite } from "../queenAudit.js";
|
|
4
|
+
import { currentActor, effectiveProjectId, getProject, isQueenLead } from "../context.js";
|
|
4
5
|
import { run } from "../result.js";
|
|
5
6
|
import { findAgent, isLive, probeFailed, summaryLiveness } from "./agents.js";
|
|
6
7
|
import { ACTIVE_TIMER_WHERE, LOG_RETENTION, OWNED_BY_WATCH, WATCH_SCOPE_PROJECT, seedGoneCursor, } from "../scheduler.js";
|
|
@@ -8,7 +9,9 @@ import { idParam, projectIdParam } from "./params.js";
|
|
|
8
9
|
import { cutToUnitBudget } from "../slug.js";
|
|
9
10
|
import { awaitingFirstPrompt } from "../firstPrompt.js";
|
|
10
11
|
import { deriveProvenance } from "../stateProvenance.js";
|
|
11
|
-
import { findUnsafeControlChar, liveTargets, TEXT_ALLOWED_CONTROL_CHARS } from "../tmux.js";
|
|
12
|
+
import { findUnsafeControlChar, liveTargets, paneReissued, rowAliveProbe, TEXT_ALLOWED_CONTROL_CHARS, } from "../tmux.js";
|
|
13
|
+
import { readLeadTurnState } from "../leadState.js";
|
|
14
|
+
import { LEAD_TARGET_GONE, LEAD_TARGET_REISSUED, leadSubscription, paneVeto, runningLeadOf } from "../leadWatch.js";
|
|
12
15
|
import { isRunningLeadActor, LEAD_KIND } from "../spawn.js";
|
|
13
16
|
import { commandHead, screenClassifiable } from "../harnesses.js";
|
|
14
17
|
const agentRefParam = z
|
|
@@ -125,6 +128,7 @@ const baseWakeFields = (t, { truncate = true } = {}) => ({
|
|
|
125
128
|
deliver_to: t.deliver_actor,
|
|
126
129
|
...(t.watch_scope ? { scope: t.watch_scope, standing: true } : {}),
|
|
127
130
|
...(t.parent_wake_id != null ? { parent_wake_id: t.parent_wake_id } : {}),
|
|
131
|
+
...leadWatchFields(t),
|
|
128
132
|
});
|
|
129
133
|
const STANDING_WATCH_LIFETIME_SECONDS = 4 * 60 * 60;
|
|
130
134
|
const openStandingWatch = db.transaction((projectId, owner, body, deliverActor, deliverPane, maxWait) => {
|
|
@@ -172,6 +176,112 @@ function createStandingWatch(projectId, args) {
|
|
|
172
176
|
"finished before the watch was set is exactly the finish a one-shot loses. Go quiet.",
|
|
173
177
|
};
|
|
174
178
|
}
|
|
179
|
+
export const LEAD_WATCH_QUEEN_ONLY = "LEAD_WATCH_QUEEN_ONLY";
|
|
180
|
+
const openLeadWatch = db.transaction((homeProjectId, body, mode, delivery, maxWait, target) => {
|
|
181
|
+
const state = readLeadTurnState(target.agentId);
|
|
182
|
+
const sameLaunch = state !== null && state.pane_pid === target.panePid;
|
|
183
|
+
const sessionId = sameLaunch && state.state !== "unknown" ? state.session_id : "";
|
|
184
|
+
const baseline = state?.idle_seq ?? 0;
|
|
185
|
+
const row = db
|
|
186
|
+
.prepare(`INSERT INTO wakes (project_id, owner, body, kind, watch, deliver_actor, deliver_pane, max_wait_at)
|
|
187
|
+
VALUES (?, ?, ?, ?, '[]', ?, ?, datetime('now', printf('+%d seconds', ?)))
|
|
188
|
+
RETURNING id`)
|
|
189
|
+
.get(homeProjectId, currentActor(), body, `idle_${mode}`, delivery.actor, delivery.pane, maxWait);
|
|
190
|
+
db.prepare(`INSERT INTO lead_idle_subscriptions (wake_id, target_project_id, agent_id, pane_pid, session_id, baseline_idle_seq)
|
|
191
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(row.id, target.projectId, target.agentId, target.panePid, sessionId, baseline);
|
|
192
|
+
return { id: row.id, session_id: sessionId, baseline_idle_seq: baseline };
|
|
193
|
+
});
|
|
194
|
+
function createLeadWatch(leadProjectId, args) {
|
|
195
|
+
if (!isQueenLead()) {
|
|
196
|
+
throw new Error(`${LEAD_WATCH_QUEEN_ONLY}: lead_project_id is the queen's alone. Only the running queen lead, in its own ` +
|
|
197
|
+
"project and without HIVE_PROJECT_LOCK, may wait on another project's lead; a project's own lead and " +
|
|
198
|
+
"every worker are refused. A worker is watched with agents=[...] or scope=\"project\" instead.");
|
|
199
|
+
}
|
|
200
|
+
if (args.project_id != null || args.deliver_to != null) {
|
|
201
|
+
throw new Error("lead_project_id refuses project_id and deliver_to: the wake is always stored in the queen's own project " +
|
|
202
|
+
"and delivered to the queen's own pane, so neither has anything to choose. Name the watched project " +
|
|
203
|
+
"with lead_project_id alone.");
|
|
204
|
+
}
|
|
205
|
+
const home = effectiveProjectId();
|
|
206
|
+
if (leadProjectId === home) {
|
|
207
|
+
throw new Error("lead_project_id names the queen's own project, and the queen cannot wait on itself.");
|
|
208
|
+
}
|
|
209
|
+
const project = getProject(leadProjectId);
|
|
210
|
+
if (!project)
|
|
211
|
+
throw new Error(`Unknown project_id ${leadProjectId}. Call project_list to see options.`);
|
|
212
|
+
const lead = runningLeadOf(leadProjectId);
|
|
213
|
+
if (!lead)
|
|
214
|
+
throw new Error(`${LEAD_TARGET_GONE}: project ${leadProjectId} ("${project.name}") has no running lead to watch.`);
|
|
215
|
+
if (!screenClassifiable(lead.command)) {
|
|
216
|
+
throw new Error(`Project ${leadProjectId}'s lead runs ${JSON.stringify(commandHead(lead.command))}, whose screen hive cannot ` +
|
|
217
|
+
"classify, so a turn ending while a human is typing to it could not be told apart from one nobody is at.");
|
|
218
|
+
}
|
|
219
|
+
if (lead.pane_pid === "") {
|
|
220
|
+
throw new Error(`${LEAD_TARGET_REISSUED}: project ${leadProjectId}'s lead has no recorded pane pid, so a reused pane id ` +
|
|
221
|
+
"could not be told from this lead. Restart it with hive lead to record one.");
|
|
222
|
+
}
|
|
223
|
+
const snapshot = liveTargets();
|
|
224
|
+
if (snapshot === null)
|
|
225
|
+
throw new Error("tmux did not answer, so the watched lead's pane cannot be verified. Try again.");
|
|
226
|
+
const probe = rowAliveProbe(lead.tmux_socket, lead.tmux_target, snapshot);
|
|
227
|
+
if (probe.live === null) {
|
|
228
|
+
throw new Error(`Project ${leadProjectId}'s lead lives on a tmux socket this process cannot probe.`);
|
|
229
|
+
}
|
|
230
|
+
if (!probe.live)
|
|
231
|
+
throw new Error(`${LEAD_TARGET_GONE}: project ${leadProjectId}'s lead pane is gone.`);
|
|
232
|
+
if (paneReissued(lead.pane_pid, probe)) {
|
|
233
|
+
throw new Error(`${LEAD_TARGET_REISSUED}: project ${leadProjectId}'s lead pane id now belongs to another process.`);
|
|
234
|
+
}
|
|
235
|
+
const mode = args.mode ?? "any";
|
|
236
|
+
const state = readLeadTurnState(lead.id);
|
|
237
|
+
const idleNow = state !== null && state.pane_pid === lead.pane_pid && state.state === "idle";
|
|
238
|
+
if (mode === "all" && idleNow && paneVeto(lead) === null) {
|
|
239
|
+
return {
|
|
240
|
+
status: "already_satisfied",
|
|
241
|
+
note: `Project ${leadProjectId}'s lead has already ended its turn; nothing was scheduled. Read it now.`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const delivery = resolveDelivery(home);
|
|
245
|
+
const maxWait = args.max_wait_seconds ?? 900;
|
|
246
|
+
const row = openLeadWatch.immediate(home, args.body, mode, delivery, maxWait, {
|
|
247
|
+
projectId: leadProjectId,
|
|
248
|
+
agentId: lead.id,
|
|
249
|
+
panePid: lead.pane_pid,
|
|
250
|
+
});
|
|
251
|
+
return {
|
|
252
|
+
wake_id: row.id,
|
|
253
|
+
mode,
|
|
254
|
+
lead_watch: {
|
|
255
|
+
project_id: leadProjectId,
|
|
256
|
+
agent_id: lead.id,
|
|
257
|
+
name: lead.name,
|
|
258
|
+
state: state !== null && state.pane_pid === lead.pane_pid ? state.state : "unknown",
|
|
259
|
+
baseline_idle_seq: row.baseline_idle_seq,
|
|
260
|
+
},
|
|
261
|
+
max_wait_seconds: maxWait,
|
|
262
|
+
deliver_to: delivery.actor,
|
|
263
|
+
note: (mode === "any"
|
|
264
|
+
? "Fires on the lead's NEXT turn ending; a turn that already ended does not count. "
|
|
265
|
+
: "Fires once the lead's turn has ended. ") +
|
|
266
|
+
"A turn ending is not the lead finishing its work: read it before acting. A dead, restarted or reissued " +
|
|
267
|
+
"lead pane ends the watch with that reason instead.",
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
function leadWatchFields(t) {
|
|
271
|
+
const sub = leadSubscription(t.id);
|
|
272
|
+
if (sub === null)
|
|
273
|
+
return {};
|
|
274
|
+
return {
|
|
275
|
+
lead_watch: {
|
|
276
|
+
project_id: sub.target_project_id,
|
|
277
|
+
agent_id: sub.agent_id,
|
|
278
|
+
pane_pid: sub.pane_pid,
|
|
279
|
+
session_id: sub.session_id,
|
|
280
|
+
baseline_idle_seq: sub.baseline_idle_seq,
|
|
281
|
+
...(sub.terminal_reason !== null ? { terminal_reason: sub.terminal_reason } : {}),
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
175
285
|
export function registerWakes(server) {
|
|
176
286
|
server.registerTool("wake_set", {
|
|
177
287
|
description: "Schedule a wake-up: after delay_seconds the body is typed into the target session's terminal " +
|
|
@@ -179,6 +289,12 @@ export function registerWakes(server) {
|
|
|
179
289
|
"instead of polling. Write the body self-contained: ids, context, next action - it may arrive in " +
|
|
180
290
|
"a session that has none of this conversation. Delivering to your OWN lead pane, where the " +
|
|
181
291
|
"context is already there, prefer the action, the ids, and a pointer to where the detail lives.",
|
|
292
|
+
annotations: {
|
|
293
|
+
readOnlyHint: false,
|
|
294
|
+
destructiveHint: false,
|
|
295
|
+
idempotentHint: false,
|
|
296
|
+
openWorldHint: false,
|
|
297
|
+
},
|
|
182
298
|
inputSchema: {
|
|
183
299
|
delay_seconds: z.number().int().positive(),
|
|
184
300
|
body: z.string(),
|
|
@@ -196,20 +312,28 @@ export function registerWakes(server) {
|
|
|
196
312
|
rejectUnsafeBody(args.body);
|
|
197
313
|
const projectId = effectiveProjectId(args.project_id);
|
|
198
314
|
const delivery = resolveDelivery(projectId, args.deliver_to);
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
315
|
+
return commitQueenStateWrite("wake_set", projectId, args, () => {
|
|
316
|
+
const row = db
|
|
317
|
+
.prepare(`INSERT INTO wakes (project_id, owner, body, kind, deliver_actor, deliver_pane, due_at, repeat_every_ms)
|
|
318
|
+
VALUES (?, ?, ?, 'delay', ?, ?, datetime('now', printf('+%d seconds', ?)), ?)
|
|
319
|
+
RETURNING id, due_at`)
|
|
320
|
+
.get(projectId, currentActor(), args.body, delivery.actor, delivery.pane, args.delay_seconds, args.repeat_every_seconds != null ? args.repeat_every_seconds * 1000 : null);
|
|
321
|
+
return {
|
|
322
|
+
wake_id: row.id,
|
|
323
|
+
due_at: row.due_at,
|
|
324
|
+
deliver_to: delivery.actor,
|
|
325
|
+
repeating: args.repeat_every_seconds != null,
|
|
326
|
+
};
|
|
327
|
+
});
|
|
210
328
|
}));
|
|
211
329
|
server.registerTool("wake_when_idle", {
|
|
212
|
-
description: "Wake up when watched agents go idle (exact state from Claude Code hooks) or max_wait_seconds passes - except delivery HOLDS past that bound instead, for as long as the target pane is on a dialog or has unsubmitted human text in it, rather than pasting the wake body into either (.claude/rules/tmux-and-panes.md). Two shapes, and you pass EXACTLY ONE of them. agents=[...] is a ONE-SHOT over a named list: mode=any fires on the first fresh idle transition, mode=all fires when every watched agent is idle (returns already_satisfied without scheduling anything if they all are now), and either way it stops watching once it fires. scope=\"project\" is a STANDING WATCH over the crew you spawn in this project, including workers spawned later: it never stops watching, and on each finish it delivers a roster naming who finished and who is still going, until max_wait_seconds runs out or you wake_cancel it. You may hold ONE standing watch per project: a second call is refused and names the one already running, since two would report every finish twice. Use the standing watch when you are running more than one worker - a one-shot leaves every other worker unwatched from the moment it fires. Use either instead of polling.
|
|
330
|
+
description: "Wake up when watched agents go idle (exact state from Claude Code hooks) or max_wait_seconds passes - except delivery HOLDS past that bound instead, for as long as the target pane is on a dialog or has unsubmitted human text in it, rather than pasting the wake body into either (.claude/rules/tmux-and-panes.md). Two shapes for workers, plus one for the queen, and you pass EXACTLY ONE of them. agents=[...] is a ONE-SHOT over a named list: mode=any fires on the first fresh idle transition, mode=all fires when every watched agent is idle (returns already_satisfied without scheduling anything if they all are now), and either way it stops watching once it fires. scope=\"project\" is a STANDING WATCH over the crew you spawn in this project, including workers spawned later: it never stops watching, and on each finish it delivers a roster naming who finished and who is still going, until max_wait_seconds runs out or you wake_cancel it. You may hold ONE standing watch per project: a second call is refused and names the one already running, since two would report every finish twice. Use the standing watch when you are running more than one worker - a one-shot leaves every other worker unwatched from the moment it fires. Use either instead of polling. agents=[...] refuses a lead: it watches worker state, which a lead does not write. lead_project_id is the QUEEN's alone: a one-shot that fires when another registered project's running lead ENDS A TURN (never 'finished its work'), stored in and delivered to the queen's own project, and ended with a named reason if that lead's pane dies, is reissued, or restarts.",
|
|
331
|
+
annotations: {
|
|
332
|
+
readOnlyHint: false,
|
|
333
|
+
destructiveHint: false,
|
|
334
|
+
idempotentHint: false,
|
|
335
|
+
openWorldHint: false,
|
|
336
|
+
},
|
|
213
337
|
inputSchema: {
|
|
214
338
|
agents: z
|
|
215
339
|
.array(agentRefParam)
|
|
@@ -232,11 +356,17 @@ export function registerWakes(server) {
|
|
|
232
356
|
"scope=\"project\": THE WATCH'S LIFETIME, default 14400 (4 hours), after which it delivers one " +
|
|
233
357
|
"last wake saying it has expired and stops watching. Not a hard deadline either way: delivery " +
|
|
234
358
|
"holds past it while the target pane is on a dialog or has unsubmitted text, until the pane clears."),
|
|
359
|
+
lead_project_id: idParam
|
|
360
|
+
.optional()
|
|
361
|
+
.describe("QUEEN ONLY: wake when the running lead of this OTHER registered project ends a turn. The wake is " +
|
|
362
|
+
"stored in the queen's own project and delivered only to the queen; the watched lead is read, never " +
|
|
363
|
+
"typed into. Mutually exclusive with agents and scope; refuses project_id and deliver_to."),
|
|
235
364
|
deliver_to: agentRefParam.optional().describe("Deliver to a spawned agent instead of this session."),
|
|
236
365
|
project_id: projectIdParam,
|
|
237
366
|
},
|
|
238
367
|
outputSchema: {
|
|
239
368
|
status: z.literal("already_satisfied").optional(),
|
|
369
|
+
lead_watch: z.record(z.string(), z.unknown()).optional(),
|
|
240
370
|
wake_id: idParam.optional(),
|
|
241
371
|
scope: z.literal("project").optional(),
|
|
242
372
|
standing: z.boolean().optional(),
|
|
@@ -258,13 +388,18 @@ export function registerWakes(server) {
|
|
|
258
388
|
}, (args) => run(() => {
|
|
259
389
|
rejectUnsafeBody(args.body);
|
|
260
390
|
const projectId = effectiveProjectId(args.project_id);
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
391
|
+
const selectors = [
|
|
392
|
+
args.agents != null && "agents",
|
|
393
|
+
args.scope != null && "scope",
|
|
394
|
+
args.lead_project_id != null && "lead_project_id",
|
|
395
|
+
].filter((x) => x !== false);
|
|
396
|
+
if (selectors.length !== 1) {
|
|
397
|
+
throw new Error("wake_when_idle needs exactly one of agents=[...] (a one-shot over a named list), " +
|
|
398
|
+
'scope="project" (a standing watch over this project\'s crew), or lead_project_id (the queen ' +
|
|
399
|
+
"waiting on another project's lead). " +
|
|
400
|
+
(selectors.length === 0
|
|
401
|
+
? "You passed none."
|
|
402
|
+
: `You passed ${selectors.length === 2 ? "both" : "all of"} ${selectors.join(" and ")}, and they mean different things.`));
|
|
268
403
|
}
|
|
269
404
|
if (args.scope != null && args.mode != null) {
|
|
270
405
|
throw new Error(`mode="${args.mode}" has no meaning for a standing watch: a standing watch reports EACH crew ` +
|
|
@@ -273,14 +408,17 @@ export function registerWakes(server) {
|
|
|
273
408
|
}
|
|
274
409
|
if (args.scope != null)
|
|
275
410
|
return createStandingWatch(projectId, args);
|
|
411
|
+
if (args.lead_project_id != null)
|
|
412
|
+
return createLeadWatch(args.lead_project_id, args);
|
|
276
413
|
const mode = args.mode ?? "any";
|
|
277
414
|
const watched = (args.agents ?? []).map((ref) => resolveAgentRef(projectId, ref));
|
|
278
415
|
const leadWatched = watched.find((a) => a.kind === LEAD_KIND);
|
|
279
416
|
if (leadWatched) {
|
|
280
|
-
throw new Error(`Agent ${leadWatched.id} ("${leadWatched.name}") is this project's lead session,
|
|
281
|
-
"
|
|
282
|
-
"wake_when_idle refuses a lead target; it could only ever fire at max_wait_seconds,
|
|
283
|
-
"as a timeout instead of the failure it actually is."
|
|
417
|
+
throw new Error(`Agent ${leadWatched.id} ("${leadWatched.name}") is this project's lead session, and agents=[...] ` +
|
|
418
|
+
"watches worker state, which a lead does not write: its hook never moves agent_state. " +
|
|
419
|
+
"wake_when_idle refuses a lead target in agents=[...]; it could only ever fire at max_wait_seconds, " +
|
|
420
|
+
"reported as a timeout instead of the failure it actually is. A lead's turn has its own channel, " +
|
|
421
|
+
"which only the queen may watch, with lead_project_id=<that lead's project id>.");
|
|
284
422
|
}
|
|
285
423
|
const delivery = resolveDelivery(projectId, args.deliver_to);
|
|
286
424
|
const snapshot = liveTargets();
|
|
@@ -299,29 +437,37 @@ export function registerWakes(server) {
|
|
|
299
437
|
}
|
|
300
438
|
}
|
|
301
439
|
const maxWait = args.max_wait_seconds ?? 900;
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
440
|
+
return commitQueenStateWrite("wake_when_idle", projectId, args, () => {
|
|
441
|
+
const info = db
|
|
442
|
+
.prepare(`INSERT INTO wakes (project_id, owner, body, kind, watch, deliver_actor, deliver_pane,
|
|
443
|
+
max_wait_at)
|
|
444
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', printf('+%d seconds', ?)))`)
|
|
445
|
+
.run(projectId, currentActor(), args.body, `idle_${mode}`, JSON.stringify(watched.map((a) => a.id)), delivery.actor, delivery.pane, maxWait);
|
|
446
|
+
return {
|
|
447
|
+
wake_id: Number(info.lastInsertRowid),
|
|
448
|
+
mode,
|
|
449
|
+
watching: watched.map((a) => {
|
|
450
|
+
const { state, ...provenance } = deriveProvenance(a, summaryLiveness(a, snapshot));
|
|
451
|
+
return { agent_id: a.id, name: a.name, state, provenance };
|
|
452
|
+
}),
|
|
453
|
+
max_wait_seconds: maxWait,
|
|
454
|
+
deliver_to: delivery.actor,
|
|
455
|
+
note: mode === "any"
|
|
456
|
+
? "Fires on the next fresh idle transition; agents already idle now do not count."
|
|
457
|
+
: "Fires when all watched agents are idle.",
|
|
458
|
+
};
|
|
459
|
+
});
|
|
320
460
|
}));
|
|
321
461
|
server.registerTool("wake_get", {
|
|
322
462
|
description: "Read one wake-up by id, in this project, with its UNTRUNCATED body. wake_list truncates " +
|
|
323
463
|
"body at 120 chars; use this to see exactly what a wake will say, or to confirm what " +
|
|
324
464
|
"wake_update just changed.",
|
|
465
|
+
annotations: {
|
|
466
|
+
readOnlyHint: false,
|
|
467
|
+
destructiveHint: false,
|
|
468
|
+
idempotentHint: true,
|
|
469
|
+
openWorldHint: false,
|
|
470
|
+
},
|
|
325
471
|
inputSchema: { wake_id: idParam, project_id: projectIdParam },
|
|
326
472
|
}, (args) => run(() => {
|
|
327
473
|
const projectId = effectiveProjectId(args.project_id);
|
|
@@ -352,6 +498,12 @@ export function registerWakes(server) {
|
|
|
352
498
|
"delay_seconds and repeat_every_seconds only apply to a delay wake (from wake_set) - an idle " +
|
|
353
499
|
"wake (from wake_when_idle) fires on watched-agent state and max_wait_seconds instead, so " +
|
|
354
500
|
"only body can be edited on one.",
|
|
501
|
+
annotations: {
|
|
502
|
+
readOnlyHint: false,
|
|
503
|
+
destructiveHint: true,
|
|
504
|
+
idempotentHint: false,
|
|
505
|
+
openWorldHint: false,
|
|
506
|
+
},
|
|
355
507
|
inputSchema: {
|
|
356
508
|
wake_id: idParam,
|
|
357
509
|
delay_seconds: z.number().int().positive().optional(),
|
|
@@ -392,12 +544,14 @@ export function registerWakes(server) {
|
|
|
392
544
|
params.push(args.delay_seconds);
|
|
393
545
|
}
|
|
394
546
|
params.push(args.wake_id, projectId, currentActor());
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
547
|
+
return commitQueenStateWrite("wake_update", projectId, args, () => {
|
|
548
|
+
const row = db
|
|
549
|
+
.prepare(`UPDATE wakes SET ${sets.join(", ")}
|
|
550
|
+
WHERE id = ? AND project_id = ? AND owner = ? AND parent_wake_id IS NULL AND ${ACTIVE_TIMER_WHERE}
|
|
551
|
+
RETURNING due_at`)
|
|
552
|
+
.get(...params);
|
|
553
|
+
return { wake_id: args.wake_id, updated: row !== undefined, due_at: row?.due_at ?? null };
|
|
554
|
+
});
|
|
401
555
|
}));
|
|
402
556
|
server.registerTool("wake_cancel", {
|
|
403
557
|
description: "Cancel a pending wake-up you own, or - if you are a running lead - any pending wake-up in this " +
|
|
@@ -409,6 +563,12 @@ export function registerWakes(server) {
|
|
|
409
563
|
"per-worker block notice (a crew member stopped on a dialog): that carries no parent link, so one " +
|
|
410
564
|
"already filed still delivers, and it may still be true - the worker is probably still on that " +
|
|
411
565
|
"dialog - but it no longer claims anything about the watch's own liveness, deliberately.",
|
|
566
|
+
annotations: {
|
|
567
|
+
readOnlyHint: false,
|
|
568
|
+
destructiveHint: true,
|
|
569
|
+
idempotentHint: true,
|
|
570
|
+
openWorldHint: false,
|
|
571
|
+
},
|
|
412
572
|
inputSchema: { wake_id: idParam, project_id: projectIdParam },
|
|
413
573
|
outputSchema: {
|
|
414
574
|
wake_id: idParam,
|
|
@@ -418,22 +578,24 @@ export function registerWakes(server) {
|
|
|
418
578
|
}, (args) => run(() => {
|
|
419
579
|
const projectId = effectiveProjectId(args.project_id);
|
|
420
580
|
const isLead = isRunningLeadActor(currentActor());
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
581
|
+
return commitQueenStateWrite("wake_cancel", projectId, args, () => {
|
|
582
|
+
const info = isLead
|
|
583
|
+
? db
|
|
584
|
+
.prepare(`UPDATE wakes SET cancelled_at = datetime('now')
|
|
585
|
+
WHERE id = ? AND project_id = ? AND cancelled_at IS NULL`)
|
|
586
|
+
.run(args.wake_id, projectId)
|
|
587
|
+
: db
|
|
588
|
+
.prepare(`UPDATE wakes SET cancelled_at = datetime('now')
|
|
589
|
+
WHERE id = ? AND project_id = ? AND owner = ? AND cancelled_at IS NULL`)
|
|
590
|
+
.run(args.wake_id, projectId, currentActor());
|
|
591
|
+
const notices = info.changes > 0
|
|
592
|
+
? db
|
|
593
|
+
.prepare(`UPDATE wakes SET cancelled_at = datetime('now')
|
|
594
|
+
WHERE parent_wake_id = ? AND cancelled_at IS NULL AND fired_at IS NULL`)
|
|
595
|
+
.run(args.wake_id).changes
|
|
596
|
+
: 0;
|
|
597
|
+
return { wake_id: args.wake_id, cancelled: info.changes > 0, cancelled_notices: notices };
|
|
598
|
+
});
|
|
437
599
|
}));
|
|
438
600
|
server.registerTool("wake_list", {
|
|
439
601
|
description: "List pending wake-ups in this project, plus recently_delivered: the last " +
|
|
@@ -441,6 +603,12 @@ export function registerWakes(server) {
|
|
|
441
603
|
"state (typed_at, held_at/held_reason, confirmation). A one-shot wake leaves the " +
|
|
442
604
|
"pending list the moment it fires; recently_delivered is where to check whether it " +
|
|
443
605
|
"was actually typed and, if its target has a confirmation channel, acknowledged.",
|
|
606
|
+
annotations: {
|
|
607
|
+
readOnlyHint: false,
|
|
608
|
+
destructiveHint: false,
|
|
609
|
+
idempotentHint: true,
|
|
610
|
+
openWorldHint: false,
|
|
611
|
+
},
|
|
444
612
|
inputSchema: { project_id: projectIdParam },
|
|
445
613
|
}, (args) => run(() => {
|
|
446
614
|
const projectId = effectiveProjectId(args.project_id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cmgmyr/hive",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
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": {
|
|
@@ -47,15 +47,15 @@
|
|
|
47
47
|
"coordination"
|
|
48
48
|
],
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@modelcontextprotocol/sdk": "^1.30.
|
|
50
|
+
"@modelcontextprotocol/sdk": "^1.30.1",
|
|
51
51
|
"better-sqlite3": "^13.0.3",
|
|
52
|
-
"smol-toml": "^1.
|
|
52
|
+
"smol-toml": "^1.9.0",
|
|
53
53
|
"yaml": "^2.9.1",
|
|
54
54
|
"zod": "^4.6.5"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/better-sqlite3": "^9.6.0",
|
|
58
|
-
"@types/node": "^26.6.
|
|
58
|
+
"@types/node": "^26.6.3",
|
|
59
59
|
"typescript": "^7.0.2"
|
|
60
60
|
},
|
|
61
61
|
"allowScripts": {
|
|
@@ -7,7 +7,7 @@ Coordinate through the hive MCP tools:
|
|
|
7
7
|
- pad_list / pad_read for the shared plan and findings. Record decisions there.
|
|
8
8
|
- todo_list(is_blocked=false, status="open") for dispatchable work; set status to in_progress while working.
|
|
9
9
|
- todo_comment for handoffs (changed files, tests run, remaining risk), then todo_complete.
|
|
10
|
-
- lease_acquire before
|
|
10
|
+
- lease_acquire before changing state another session can also change (a shared checkout, a dev database, a port); files in a directory only you work in need none. Leases expire on their own.
|
|
11
11
|
<!--if:primary_root-->
|
|
12
12
|
The primary checkout is at {{primary_root}} - untracked project files (a session corpus, local notes) live there, not in your worktree, if this project keeps any.
|
|
13
13
|
<!--end-->
|