@cmgmyr/hive 0.1.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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/claude-plugin/.claude-plugin/plugin.json +7 -0
  4. package/claude-plugin/README.md +47 -0
  5. package/claude-plugin/hooks/hooks.json +14 -0
  6. package/claude-plugin/kickoff.mjs +41 -0
  7. package/claude-plugin/skills/cleanup/SKILL.md +60 -0
  8. package/dist/abi.js +220 -0
  9. package/dist/abiProbe.js +9 -0
  10. package/dist/backgroundTasks.js +51 -0
  11. package/dist/backup.js +378 -0
  12. package/dist/brief.js +113 -0
  13. package/dist/build-info.json +1 -0
  14. package/dist/claudeDir.js +5 -0
  15. package/dist/cli.js +2901 -0
  16. package/dist/codexHome.js +297 -0
  17. package/dist/config.js +72 -0
  18. package/dist/context.js +256 -0
  19. package/dist/contextCheckpoint.js +55 -0
  20. package/dist/dashboard.js +1158 -0
  21. package/dist/dataDir.js +89 -0
  22. package/dist/db.js +421 -0
  23. package/dist/dispatcher.js +150 -0
  24. package/dist/firstPrompt.js +6 -0
  25. package/dist/harnesses.js +173 -0
  26. package/dist/help.js +353 -0
  27. package/dist/hook.js +197 -0
  28. package/dist/hooks.js +50 -0
  29. package/dist/index.js +34 -0
  30. package/dist/kickoff.js +191 -0
  31. package/dist/leadMessage.js +98 -0
  32. package/dist/mcpConfig.js +121 -0
  33. package/dist/processes.js +161 -0
  34. package/dist/profiles.js +278 -0
  35. package/dist/projectYml.js +254 -0
  36. package/dist/prompts.js +18 -0
  37. package/dist/ptys.js +146 -0
  38. package/dist/result.js +53 -0
  39. package/dist/scheduler.js +1622 -0
  40. package/dist/sessionProbe.js +131 -0
  41. package/dist/slug.js +29 -0
  42. package/dist/spawn.js +370 -0
  43. package/dist/stateProvenance.js +97 -0
  44. package/dist/statusline.js +82 -0
  45. package/dist/strictInput.js +52 -0
  46. package/dist/teardown.js +89 -0
  47. package/dist/tmux.js +1295 -0
  48. package/dist/tools/agents.js +1246 -0
  49. package/dist/tools/kv.js +89 -0
  50. package/dist/tools/leases.js +87 -0
  51. package/dist/tools/meta.js +183 -0
  52. package/dist/tools/pads.js +279 -0
  53. package/dist/tools/params.js +14 -0
  54. package/dist/tools/todos.js +382 -0
  55. package/dist/tools/wakes.js +466 -0
  56. package/dist/transcript.js +123 -0
  57. package/dist/version.js +59 -0
  58. package/package.json +62 -0
  59. package/profiles/orchestration/posture.md +27 -0
  60. package/profiles/orchestration/runbook.md +72 -0
  61. package/profiles/orchestration/worker.md +38 -0
  62. package/profiles/simple/posture.md +14 -0
@@ -0,0 +1,466 @@
1
+ import { z } from "zod";
2
+ import { db } from "../db.js";
3
+ import { currentActor, effectiveProjectId } from "../context.js";
4
+ import { run } from "../result.js";
5
+ import { findAgent, isLive, probeFailed, summaryLiveness } from "./agents.js";
6
+ import { ACTIVE_TIMER_WHERE, LOG_RETENTION, OWNED_BY_WATCH, WATCH_SCOPE_PROJECT, seedGoneCursor, } from "../scheduler.js";
7
+ import { idParam, projectIdParam } from "./params.js";
8
+ import { cutToUnitBudget } from "../slug.js";
9
+ import { awaitingFirstPrompt } from "../firstPrompt.js";
10
+ import { deriveProvenance } from "../stateProvenance.js";
11
+ import { findUnsafeControlChar, liveTargets, TEXT_ALLOWED_CONTROL_CHARS } from "../tmux.js";
12
+ import { isRunningLeadActor, LEAD_KIND } from "../spawn.js";
13
+ import { commandHead, screenClassifiable } from "../harnesses.js";
14
+ const agentRefParam = z
15
+ .union([idParam, z.string()])
16
+ .describe("Running agent's name (preferred), or its numeric agent id.");
17
+ function resolveAgentRef(projectId, ref) {
18
+ return typeof ref === "number"
19
+ ? findAgent(projectId, { agent_id: ref })
20
+ : findAgent(projectId, { name: ref });
21
+ }
22
+ function refuseUnclassifiableTarget(agent) {
23
+ if (screenClassifiable(agent.command))
24
+ return;
25
+ throw new Error(`Agent ${agent.id} ("${agent.name}") runs ${JSON.stringify(commandHead(agent.command))}, and hive can only ` +
26
+ "classify a claude screen. A wake is delivered by pasting its body and pressing Enter, and the two guards " +
27
+ "that decide whether that is safe - a dialog on screen, unsubmitted human text in the box - both read " +
28
+ "claude's own chrome, so on this pane neither can answer. Delivery would type blind: into a dialog it " +
29
+ "answers the highlighted option, and on a shell it executes whatever the paste merged with. " +
30
+ "Refused here rather than at delivery, where it could only ever be held forever - a pane's harness never " +
31
+ "becomes classifiable, so that hold would never clear, and a timer that cannot fire is worse than an " +
32
+ "error you can read now. Drive this pane with agent_send(keys: [...]) instead, which is unguarded for " +
33
+ "exactly this reason.");
34
+ }
35
+ function resolveDelivery(projectId, deliverTo) {
36
+ if (deliverTo != null) {
37
+ const agent = resolveAgentRef(projectId, deliverTo);
38
+ // Before the liveness probe: the harness is a durable fact about the row, while liveness is a
39
+ // question about right now, so a dead bash worker should say what is actually wrong with it.
40
+ refuseUnclassifiableTarget(agent);
41
+ const live = isLive(agent);
42
+ if (live === null)
43
+ throw probeFailed(agent);
44
+ if (!live) {
45
+ throw new Error(`Agent "${agent.name}" has no live tmux window to deliver to.`);
46
+ }
47
+ return { actor: agent.actor_id, pane: agent.tmux_target };
48
+ }
49
+ const actor = currentActor();
50
+ const own = db
51
+ .prepare("SELECT * FROM agents WHERE actor_id = ? AND status = 'running' ORDER BY id DESC LIMIT 1")
52
+ .get(actor);
53
+ // Checked on the ROW, not behind the liveness probe: isLive is three-state, and letting a null
54
+ // fall through to the TMUX_PANE branch below accepts a wake the scheduler will then hold forever
55
+ // against that same row's command - the never-firing timer this refusal exists to prevent.
56
+ if (own)
57
+ refuseUnclassifiableTarget(own);
58
+ if (own && isLive(own) === true) {
59
+ return { actor, pane: own.tmux_target };
60
+ }
61
+ const pane = process.env.TMUX_PANE;
62
+ if (pane)
63
+ return { actor, pane };
64
+ throw new Error("This session cannot receive wake-ups: it is not running inside tmux, so nothing can be typed into its terminal. Start the lead inside tmux (run tmux, then claude; watch with tmux attach, or tmux -CC attach for iTerm's native windows), or pass deliver_to targeting a spawned agent.");
65
+ }
66
+ function pendingWakes(projectId) {
67
+ return db
68
+ .prepare(`SELECT * FROM wakes WHERE project_id = ? AND ${ACTIVE_TIMER_WHERE} ORDER BY id`)
69
+ .all(projectId);
70
+ }
71
+ const RECENTLY_FIRED_LIMIT = 10;
72
+ function recentlyFiredWakes(projectId) {
73
+ return db
74
+ .prepare(`SELECT * FROM wakes
75
+ WHERE project_id = ? AND cancelled_at IS NULL AND fired_at IS NOT NULL AND repeat_every_ms IS NULL
76
+ AND fired_at >= datetime('now', ?)
77
+ ORDER BY fired_at DESC, id DESC LIMIT ${RECENTLY_FIRED_LIMIT}`)
78
+ .all(projectId, LOG_RETENTION);
79
+ }
80
+ function makeChannelChecker() {
81
+ const known = new Map();
82
+ return (actorId) => {
83
+ let has = known.get(actorId);
84
+ if (has === undefined) {
85
+ has = db.prepare("SELECT 1 FROM agents WHERE actor_id = ?").get(actorId) !== undefined;
86
+ known.set(actorId, has);
87
+ }
88
+ return has;
89
+ };
90
+ }
91
+ function deliveryState(t, hasChannel) {
92
+ return {
93
+ typed_at: t.typed_at,
94
+ typed_seen: t.typed_seen,
95
+ held_at: t.held_at,
96
+ held_reason: t.held_reason,
97
+ confirmed_at: t.confirmed_at,
98
+ confirmation: t.typed_at == null
99
+ ? null
100
+ : t.confirmed_at != null
101
+ ? "confirmed"
102
+ : !hasChannel(t.deliver_actor)
103
+ ? "no_confirmation_channel"
104
+ : t.typed_busy === 1
105
+ ? "unconfirmed_busy"
106
+ : "unconfirmed",
107
+ };
108
+ }
109
+ export const truncateBody = (body) => body.length > 120 ? `${cutToUnitBudget(body, 120)}…` : body;
110
+ function rejectUnsafeBody(body) {
111
+ const bad = findUnsafeControlChar(body, TEXT_ALLOWED_CONTROL_CHARS);
112
+ if (bad) {
113
+ throw new Error(`body cannot contain ${bad.label} at offset ${bad.index}: it is typed literally into the target pane ` +
114
+ "when the wake fires, and a raw control byte reaches tmux as a keystroke instead of as text, " +
115
+ "silently turning the delivery into a keys call nobody chose. Tab and newline are the only control " +
116
+ "characters allowed - every wake in this project is multi-line prose. To send an actual keystroke " +
117
+ 'on purpose, call agent_send(keys: [...]) against the target directly instead of scheduling it here.');
118
+ }
119
+ }
120
+ const baseWakeFields = (t, { truncate = true } = {}) => ({
121
+ wake_id: t.id,
122
+ kind: t.kind,
123
+ body: truncate ? truncateBody(t.body) : t.body,
124
+ owner: t.owner,
125
+ deliver_to: t.deliver_actor,
126
+ ...(t.watch_scope ? { scope: t.watch_scope, standing: true } : {}),
127
+ ...(t.parent_wake_id != null ? { parent_wake_id: t.parent_wake_id } : {}),
128
+ });
129
+ const STANDING_WATCH_LIFETIME_SECONDS = 4 * 60 * 60;
130
+ const openStandingWatch = db.transaction((projectId, owner, body, deliverActor, deliverPane, maxWait) => {
131
+ const existing = db
132
+ .prepare(`SELECT id, max_wait_at FROM wakes
133
+ WHERE project_id = ? AND owner = ? AND watch_scope IS NOT NULL AND ${ACTIVE_TIMER_WHERE}
134
+ ORDER BY id LIMIT 1`)
135
+ .get(projectId, owner);
136
+ if (existing !== undefined) {
137
+ throw new Error(`You already have a standing watch on this project: wake #${existing.id}, which expires at ` +
138
+ `${existing.max_wait_at ?? "an unrecorded time"}. It is still watching the crew you spawned, ` +
139
+ "including workers spawned since you set it, so a second one would report every finish twice and " +
140
+ `leave two wake ids to cancel. Use it, or wake_cancel(wake_id: ${existing.id}) first if you want to ` +
141
+ "change its body, its lifetime or its deliver_to.");
142
+ }
143
+ const row = db
144
+ .prepare(`INSERT INTO wakes (project_id, owner, body, kind, watch_scope, deliver_actor, deliver_pane, max_wait_at)
145
+ VALUES (?, ?, ?, 'idle_any', ?, ?, ?, datetime('now', printf('+%d seconds', ?)))
146
+ RETURNING id, max_wait_at`)
147
+ .get(projectId, owner, body, WATCH_SCOPE_PROJECT, deliverActor, deliverPane, maxWait);
148
+ seedGoneCursor(row.id, projectId);
149
+ return row;
150
+ });
151
+ function createStandingWatch(projectId, args) {
152
+ const delivery = resolveDelivery(projectId, args.deliver_to);
153
+ const maxWait = args.max_wait_seconds ?? STANDING_WATCH_LIFETIME_SECONDS;
154
+ const row = openStandingWatch.immediate(projectId, currentActor(), args.body, delivery.actor, delivery.pane, maxWait);
155
+ const crew = db
156
+ .prepare(`SELECT name FROM agents a
157
+ WHERE a.project_id = ? AND a.kind = 'agent' AND a.status = 'running' AND a.actor_id != ?
158
+ ${OWNED_BY_WATCH}
159
+ ORDER BY a.id`)
160
+ .all(projectId, currentActor(), currentActor());
161
+ return {
162
+ wake_id: row.id,
163
+ scope: WATCH_SCOPE_PROJECT,
164
+ standing: true,
165
+ watching_now: crew.map((c) => c.name),
166
+ expires_at: row.max_wait_at,
167
+ max_wait_seconds: maxWait,
168
+ deliver_to: delivery.actor,
169
+ note: "Standing watch: it reports EACH crew member as it finishes or goes away, including workers spawned " +
170
+ "after this call, and keeps watching until it expires or you wake_cancel it. Agents already idle now " +
171
+ "DO count and are reported on the first tick - unlike mode=any, deliberately, because a worker that " +
172
+ "finished before the watch was set is exactly the finish a one-shot loses. Go quiet.",
173
+ };
174
+ }
175
+ export function registerWakes(server) {
176
+ server.registerTool("wake_set", {
177
+ description: "Schedule a wake-up: after delay_seconds the body is typed into the target session's terminal " +
178
+ "as a fresh user turn (prefixed [hive wake #N]). Defaults to delivering to THIS session. Use " +
179
+ "instead of polling. Write the body self-contained: ids, context, next action - it may arrive in " +
180
+ "a session that has none of this conversation. Delivering to your OWN lead pane, where the " +
181
+ "context is already there, prefer the action, the ids, and a pointer to where the detail lives.",
182
+ inputSchema: {
183
+ delay_seconds: z.number().int().positive(),
184
+ body: z.string(),
185
+ deliver_to: agentRefParam.optional().describe("Deliver to a spawned agent instead of this session."),
186
+ repeat_every_seconds: z.number().int().positive().optional(),
187
+ project_id: projectIdParam,
188
+ },
189
+ outputSchema: {
190
+ wake_id: idParam,
191
+ due_at: z.string(),
192
+ deliver_to: z.string(),
193
+ repeating: z.boolean(),
194
+ },
195
+ }, (args) => run(() => {
196
+ rejectUnsafeBody(args.body);
197
+ const projectId = effectiveProjectId(args.project_id);
198
+ const delivery = resolveDelivery(projectId, args.deliver_to);
199
+ const row = db
200
+ .prepare(`INSERT INTO wakes (project_id, owner, body, kind, deliver_actor, deliver_pane, due_at, repeat_every_ms)
201
+ VALUES (?, ?, ?, 'delay', ?, ?, datetime('now', printf('+%d seconds', ?)), ?)
202
+ RETURNING id, due_at`)
203
+ .get(projectId, currentActor(), args.body, delivery.actor, delivery.pane, args.delay_seconds, args.repeat_every_seconds != null ? args.repeat_every_seconds * 1000 : null);
204
+ return {
205
+ wake_id: row.id,
206
+ due_at: row.due_at,
207
+ deliver_to: delivery.actor,
208
+ repeating: args.repeat_every_seconds != null,
209
+ };
210
+ }));
211
+ 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. Refuses a lead target: a lead has no idle/working state channel.",
213
+ inputSchema: {
214
+ agents: z
215
+ .array(agentRefParam)
216
+ .min(1)
217
+ .optional()
218
+ .describe("Agents to watch, as a ONE-SHOT. Mutually exclusive with scope."),
219
+ body: z.string(),
220
+ mode: z.enum(["any", "all"]).optional().describe("Defaults to any. Only meaningful with agents."),
221
+ scope: z
222
+ .enum(["project"])
223
+ .optional()
224
+ .describe("Watch the crew you spawn in this project as a STANDING watch that keeps watching after each " +
225
+ "finish, including workers spawned later. Mutually exclusive with agents."),
226
+ max_wait_seconds: z
227
+ .number()
228
+ .int()
229
+ .positive()
230
+ .optional()
231
+ .describe("For agents=[...]: how long to wait for idle before firing anyway, default 900. For " +
232
+ "scope=\"project\": THE WATCH'S LIFETIME, default 14400 (4 hours), after which it delivers one " +
233
+ "last wake saying it has expired and stops watching. Not a hard deadline either way: delivery " +
234
+ "holds past it while the target pane is on a dialog or has unsubmitted text, until the pane clears."),
235
+ deliver_to: agentRefParam.optional().describe("Deliver to a spawned agent instead of this session."),
236
+ project_id: projectIdParam,
237
+ },
238
+ outputSchema: {
239
+ status: z.literal("already_satisfied").optional(),
240
+ wake_id: idParam.optional(),
241
+ scope: z.literal("project").optional(),
242
+ standing: z.boolean().optional(),
243
+ watching_now: z.array(z.string()).optional(),
244
+ mode: z.enum(["any", "all"]).optional(),
245
+ watching: z
246
+ .array(z.object({
247
+ agent_id: idParam,
248
+ name: z.string(),
249
+ state: z.string(),
250
+ provenance: z.record(z.string(), z.unknown()),
251
+ }))
252
+ .optional(),
253
+ expires_at: z.string().optional(),
254
+ max_wait_seconds: z.number().optional(),
255
+ deliver_to: z.string().optional(),
256
+ note: z.string().optional(),
257
+ },
258
+ }, (args) => run(() => {
259
+ rejectUnsafeBody(args.body);
260
+ const projectId = effectiveProjectId(args.project_id);
261
+ if ((args.agents == null) === (args.scope == null)) {
262
+ throw new Error("wake_when_idle needs exactly one of agents=[...] (a one-shot over a named list) or " +
263
+ 'scope="project" (a standing watch over this project\'s crew). ' +
264
+ (args.agents == null
265
+ ? "You passed neither."
266
+ : "You passed both, and they mean different things: a standing watch computes its own " +
267
+ "membership every tick, so the list would be ignored."));
268
+ }
269
+ if (args.scope != null && args.mode != null) {
270
+ throw new Error(`mode="${args.mode}" has no meaning for a standing watch: a standing watch reports EACH crew ` +
271
+ "member as it finishes, rather than firing once on the first (any) or once on the last (all). " +
272
+ "Drop mode, or use agents=[...] if you want one of those two.");
273
+ }
274
+ if (args.scope != null)
275
+ return createStandingWatch(projectId, args);
276
+ const mode = args.mode ?? "any";
277
+ const watched = (args.agents ?? []).map((ref) => resolveAgentRef(projectId, ref));
278
+ const leadWatched = watched.find((a) => a.kind === LEAD_KIND);
279
+ if (leadWatched) {
280
+ throw new Error(`Agent ${leadWatched.id} ("${leadWatched.name}") is this project's lead session, which has no ` +
281
+ "idle/working state channel: its hook writes only agent_state_log, never agent_state. " +
282
+ "wake_when_idle refuses a lead target; it could only ever fire at max_wait_seconds, reported " +
283
+ "as a timeout instead of the failure it actually is.");
284
+ }
285
+ const delivery = resolveDelivery(projectId, args.deliver_to);
286
+ const snapshot = liveTargets();
287
+ if (mode === "all") {
288
+ const allIdle = watched.every((a) => {
289
+ const live = summaryLiveness(a, snapshot);
290
+ if (awaitingFirstPrompt(a))
291
+ return false;
292
+ return live === false || (live === true && a.agent_state === "idle");
293
+ });
294
+ if (allIdle) {
295
+ return {
296
+ status: "already_satisfied",
297
+ note: "Every watched agent is already idle; nothing was scheduled. Act now.",
298
+ };
299
+ }
300
+ }
301
+ const maxWait = args.max_wait_seconds ?? 900;
302
+ const info = db
303
+ .prepare(`INSERT INTO wakes (project_id, owner, body, kind, watch, deliver_actor, deliver_pane,
304
+ max_wait_at)
305
+ VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', printf('+%d seconds', ?)))`)
306
+ .run(projectId, currentActor(), args.body, `idle_${mode}`, JSON.stringify(watched.map((a) => a.id)), delivery.actor, delivery.pane, maxWait);
307
+ return {
308
+ wake_id: Number(info.lastInsertRowid),
309
+ mode,
310
+ watching: watched.map((a) => {
311
+ const { state, ...provenance } = deriveProvenance(a, summaryLiveness(a, snapshot));
312
+ return { agent_id: a.id, name: a.name, state, provenance };
313
+ }),
314
+ max_wait_seconds: maxWait,
315
+ deliver_to: delivery.actor,
316
+ note: mode === "any"
317
+ ? "Fires on the next fresh idle transition; agents already idle now do not count."
318
+ : "Fires when all watched agents are idle.",
319
+ };
320
+ }));
321
+ server.registerTool("wake_get", {
322
+ description: "Read one wake-up by id, in this project, with its UNTRUNCATED body. wake_list truncates " +
323
+ "body at 120 chars; use this to see exactly what a wake will say, or to confirm what " +
324
+ "wake_update just changed.",
325
+ inputSchema: { wake_id: idParam, project_id: projectIdParam },
326
+ }, (args) => run(() => {
327
+ const projectId = effectiveProjectId(args.project_id);
328
+ const t = db
329
+ .prepare("SELECT * FROM wakes WHERE id = ? AND project_id = ?")
330
+ .get(args.wake_id, projectId);
331
+ if (!t)
332
+ throw new Error(`Wake ${args.wake_id} not found in this project.`);
333
+ const hasChannel = makeChannelChecker();
334
+ return {
335
+ ...baseWakeFields(t, { truncate: false }),
336
+ due_at: t.due_at,
337
+ max_wait_at: t.max_wait_at,
338
+ repeat_every_seconds: t.repeat_every_ms != null ? t.repeat_every_ms / 1000 : null,
339
+ repeating: t.repeat_every_ms != null,
340
+ fire_count: t.fire_count,
341
+ cancelled_at: t.cancelled_at,
342
+ first_held_at: t.first_held_at,
343
+ ...deliveryState(t, hasChannel),
344
+ };
345
+ }));
346
+ server.registerTool("wake_update", {
347
+ description: "Edit a pending wake-up you own, in place, without minting a new id. Provide any subset of " +
348
+ "delay_seconds, body, repeat_every_seconds. delay_seconds is RELATIVE TO NOW, exactly as in " +
349
+ "wake_set: it moves the next fire time to now + delay_seconds. repeat_every_seconds only " +
350
+ "changes the interval used for firings AFTER this one; on its own it does not move the next " +
351
+ "fire time. Only a still-pending wake can be edited; use wake_get to read the result back. " +
352
+ "delay_seconds and repeat_every_seconds only apply to a delay wake (from wake_set) - an idle " +
353
+ "wake (from wake_when_idle) fires on watched-agent state and max_wait_seconds instead, so " +
354
+ "only body can be edited on one.",
355
+ inputSchema: {
356
+ wake_id: idParam,
357
+ delay_seconds: z.number().int().positive().optional(),
358
+ body: z.string().optional(),
359
+ repeat_every_seconds: z.number().int().positive().optional(),
360
+ project_id: projectIdParam,
361
+ },
362
+ outputSchema: { wake_id: idParam, updated: z.boolean(), due_at: z.string().nullable() },
363
+ }, (args) => run(() => {
364
+ if (args.delay_seconds == null && args.body == null && args.repeat_every_seconds == null) {
365
+ throw new Error("wake_update requires at least one of delay_seconds, body, repeat_every_seconds.");
366
+ }
367
+ const projectId = effectiveProjectId(args.project_id);
368
+ if (args.delay_seconds != null || args.repeat_every_seconds != null) {
369
+ const target = db
370
+ .prepare(`SELECT kind FROM wakes WHERE id = ? AND project_id = ? AND owner = ? AND parent_wake_id IS NULL
371
+ AND ${ACTIVE_TIMER_WHERE}`)
372
+ .get(args.wake_id, projectId, currentActor());
373
+ if (target && target.kind !== "delay") {
374
+ throw new Error(`Wake ${args.wake_id} is a ${target.kind} wake (from wake_when_idle): it fires on ` +
375
+ "watched-agent state and max_wait_seconds, not due_at, so delay_seconds and " +
376
+ "repeat_every_seconds have no effect on it. Only body can be edited on it.");
377
+ }
378
+ }
379
+ const sets = [];
380
+ const params = [];
381
+ if (args.body != null) {
382
+ rejectUnsafeBody(args.body);
383
+ sets.push("body = ?");
384
+ params.push(args.body);
385
+ }
386
+ if (args.repeat_every_seconds != null) {
387
+ sets.push("repeat_every_ms = ?");
388
+ params.push(args.repeat_every_seconds * 1000);
389
+ }
390
+ if (args.delay_seconds != null) {
391
+ sets.push("due_at = datetime('now', printf('+%d seconds', ?))");
392
+ params.push(args.delay_seconds);
393
+ }
394
+ params.push(args.wake_id, projectId, currentActor());
395
+ const row = db
396
+ .prepare(`UPDATE wakes SET ${sets.join(", ")}
397
+ WHERE id = ? AND project_id = ? AND owner = ? AND parent_wake_id IS NULL AND ${ACTIVE_TIMER_WHERE}
398
+ RETURNING due_at`)
399
+ .get(...params);
400
+ return { wake_id: args.wake_id, updated: row !== undefined, due_at: row?.due_at ?? null };
401
+ }));
402
+ server.registerTool("wake_cancel", {
403
+ description: "Cancel a pending wake-up you own, or - if you are a running lead - any pending wake-up in this " +
404
+ "project. Cancelling any wake also cancels the hold notices already filed about IT (modal-hold, " +
405
+ "unsubmitted-input, and one-shot block), since a notice about a wake that no longer exists has " +
406
+ "nothing left to say; unlike a finish notice these never expire on their own, since the thing they " +
407
+ "report may well still be true an hour later. Cancelling a standing watch also cancels the FINISH " +
408
+ "notices it has already filed but not yet delivered. It does NOT cancel a standing watch's own " +
409
+ "per-worker block notice (a crew member stopped on a dialog): that carries no parent link, so one " +
410
+ "already filed still delivers, and it may still be true - the worker is probably still on that " +
411
+ "dialog - but it no longer claims anything about the watch's own liveness, deliberately.",
412
+ inputSchema: { wake_id: idParam, project_id: projectIdParam },
413
+ outputSchema: {
414
+ wake_id: idParam,
415
+ cancelled: z.boolean(),
416
+ cancelled_notices: z.number().int().nonnegative(),
417
+ },
418
+ }, (args) => run(() => {
419
+ const projectId = effectiveProjectId(args.project_id);
420
+ const isLead = isRunningLeadActor(currentActor());
421
+ const info = isLead
422
+ ? db
423
+ .prepare(`UPDATE wakes SET cancelled_at = datetime('now')
424
+ WHERE id = ? AND project_id = ? AND cancelled_at IS NULL`)
425
+ .run(args.wake_id, projectId)
426
+ : db
427
+ .prepare(`UPDATE wakes SET cancelled_at = datetime('now')
428
+ WHERE id = ? AND project_id = ? AND owner = ? AND cancelled_at IS NULL`)
429
+ .run(args.wake_id, projectId, currentActor());
430
+ const notices = info.changes > 0
431
+ ? db
432
+ .prepare(`UPDATE wakes SET cancelled_at = datetime('now')
433
+ WHERE parent_wake_id = ? AND cancelled_at IS NULL AND fired_at IS NULL`)
434
+ .run(args.wake_id).changes
435
+ : 0;
436
+ return { wake_id: args.wake_id, cancelled: info.changes > 0, cancelled_notices: notices };
437
+ }));
438
+ server.registerTool("wake_list", {
439
+ description: "List pending wake-ups in this project, plus recently_delivered: the last " +
440
+ `${RECENTLY_FIRED_LIMIT} one-shot wakes that have already fired, with their delivery ` +
441
+ "state (typed_at, held_at/held_reason, confirmation). A one-shot wake leaves the " +
442
+ "pending list the moment it fires; recently_delivered is where to check whether it " +
443
+ "was actually typed and, if its target has a confirmation channel, acknowledged.",
444
+ inputSchema: { project_id: projectIdParam },
445
+ }, (args) => run(() => {
446
+ const projectId = effectiveProjectId(args.project_id);
447
+ const hasChannel = makeChannelChecker();
448
+ return {
449
+ project_id: projectId,
450
+ wakes: pendingWakes(projectId).map((t) => ({
451
+ ...baseWakeFields(t),
452
+ due_at: t.due_at,
453
+ max_wait_at: t.max_wait_at,
454
+ repeating: t.repeat_every_ms != null,
455
+ fire_count: t.fire_count,
456
+ ...deliveryState(t, hasChannel),
457
+ })),
458
+ recently_delivered: recentlyFiredWakes(projectId).map((t) => ({
459
+ ...baseWakeFields(t),
460
+ fired_at: t.fired_at,
461
+ fire_count: t.fire_count,
462
+ ...deliveryState(t, hasChannel),
463
+ })),
464
+ };
465
+ }));
466
+ }
@@ -0,0 +1,123 @@
1
+ import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { storeDir } from "./dataDir.js";
4
+ import { claudeConfigDir } from "./claudeDir.js";
5
+ export function transcriptDirName(cwd) {
6
+ return cwd.replace(/[/.]/g, "-");
7
+ }
8
+ export function transcriptDir(cwd) {
9
+ return join(claudeConfigDir(), "projects", transcriptDirName(cwd));
10
+ }
11
+ export function resolveTranscriptDir(cwd) {
12
+ const dir = transcriptDir(cwd);
13
+ return existsSync(dir) ? dir : null;
14
+ }
15
+ export const CONTEXT_TOKENS_TAIL_BYTES = 256 * 1024;
16
+ function object(value) {
17
+ return typeof value === "object" && value !== null ? value : null;
18
+ }
19
+ function tokenNumber(value) {
20
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
21
+ }
22
+ function usageTokens(record) {
23
+ if (record.type !== "assistant")
24
+ return null;
25
+ const usage = object(object(record.message)?.usage);
26
+ if (!usage)
27
+ return null;
28
+ const values = [usage.input_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens];
29
+ if (values.some((value) => value !== undefined && !tokenNumber(value)))
30
+ return null;
31
+ const sum = values.reduce((total, value) => total + (value ?? 0), 0);
32
+ return tokenNumber(sum) && sum > 0 ? sum : null;
33
+ }
34
+ function tailRecords(path) {
35
+ let fd;
36
+ try {
37
+ fd = openSync(path, "r");
38
+ const size = fstatSync(fd).size;
39
+ const start = Math.max(0, size - CONTEXT_TOKENS_TAIL_BYTES);
40
+ const buffer = Buffer.alloc(size - start);
41
+ const bytes = readSync(fd, buffer, 0, buffer.length, start);
42
+ const lines = buffer.subarray(0, bytes).toString("utf8").split("\n");
43
+ if (start > 0)
44
+ lines.shift();
45
+ const records = [];
46
+ for (let i = lines.length - 1; i >= 0; i--) {
47
+ try {
48
+ const record = object(JSON.parse(lines[i]));
49
+ if (record)
50
+ records.push(record);
51
+ }
52
+ catch { }
53
+ }
54
+ return records;
55
+ }
56
+ catch {
57
+ return [];
58
+ }
59
+ finally {
60
+ if (fd !== undefined)
61
+ closeSync(fd);
62
+ }
63
+ }
64
+ function claudeTokens(records) {
65
+ for (const record of records) {
66
+ const tokens = usageTokens(record);
67
+ if (tokens === null)
68
+ continue;
69
+ return tokens;
70
+ }
71
+ return null;
72
+ }
73
+ export function claudeWindowPath(actorId) {
74
+ return join(storeDir(), "context-windows", `${encodeURIComponent(actorId)}.json`);
75
+ }
76
+ function claudeWindow(actorId) {
77
+ if (!actorId)
78
+ return null;
79
+ try {
80
+ const value = JSON.parse(readFileSync(claudeWindowPath(actorId), "utf8"));
81
+ return tokenNumber(value) && value > 0 ? value : null;
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ export function readContextFill(kind, worker) {
88
+ const path = worker.transcript_path || (kind === "claude" && worker.session_id
89
+ ? join(transcriptDir(worker.cwd), `${worker.session_id}.jsonl`) : "");
90
+ if (!path)
91
+ return null;
92
+ let window = kind === "claude" ? claudeWindow(worker.actor_id) : null;
93
+ if (kind === "claude" && window === null)
94
+ return null;
95
+ const records = tailRecords(path);
96
+ let used = null;
97
+ if (kind === "claude") {
98
+ used = claudeTokens(records);
99
+ }
100
+ else {
101
+ for (const record of records) {
102
+ const payload = object(record.payload);
103
+ if (record.type !== "event_msg" || payload?.type !== "token_count")
104
+ continue;
105
+ const info = object(payload.info);
106
+ const input = object(info?.last_token_usage)?.input_tokens;
107
+ const size = info?.model_context_window;
108
+ if (!tokenNumber(input) || !tokenNumber(size) || size === 0)
109
+ continue;
110
+ used = input;
111
+ window = size;
112
+ break;
113
+ }
114
+ }
115
+ return used === null || window === null ? null : {
116
+ used_tokens: used, window_tokens: window, used_percent: Math.round(used * 100 / window),
117
+ };
118
+ }
119
+ export function readContextTokens(cwd, sessionId, transcriptPath = "") {
120
+ if (!sessionId && !transcriptPath)
121
+ return null;
122
+ return claudeTokens(tailRecords(transcriptPath || join(transcriptDir(cwd), `${sessionId}.jsonl`)));
123
+ }
@@ -0,0 +1,59 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
6
+ const checkoutRoot = join(moduleDir, "..");
7
+ function readBuildInfo() {
8
+ try {
9
+ const raw = JSON.parse(readFileSync(join(moduleDir, "build-info.json"), "utf8"));
10
+ return typeof raw.version === "string" ? raw : null;
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ function packageVersion() {
17
+ try {
18
+ const raw = JSON.parse(readFileSync(join(checkoutRoot, "package.json"), "utf8"));
19
+ return typeof raw.version === "string" ? raw.version : "0.0.0";
20
+ }
21
+ catch {
22
+ return "0.0.0";
23
+ }
24
+ }
25
+ function currentGit() {
26
+ try {
27
+ const sha = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
28
+ cwd: checkoutRoot,
29
+ encoding: "utf8",
30
+ stdio: ["ignore", "pipe", "ignore"],
31
+ }).trim();
32
+ const status = execFileSync("git", ["status", "--porcelain"], {
33
+ cwd: checkoutRoot,
34
+ encoding: "utf8",
35
+ stdio: ["ignore", "pipe", "ignore"],
36
+ });
37
+ return { sha, dirty: status.trim().length > 0 };
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ const describe = (sha, dirty) => `${sha}${dirty ? "-dirty" : ""}`;
44
+ export function versionInfo() {
45
+ const info = readBuildInfo();
46
+ const version = info?.version ?? packageVersion();
47
+ if (!info || info.sha == null) {
48
+ const now = currentGit();
49
+ return now
50
+ ? { line: `hive ${version} (${describe(now.sha, now.dirty)})`, drift: null }
51
+ : { line: `hive ${version} (build unknown; not built from a git checkout)`, drift: null };
52
+ }
53
+ const line = `hive ${version} (${describe(info.sha, info.dirty)})`;
54
+ const now = currentGit();
55
+ if (now && (now.sha !== info.sha || now.dirty !== info.dirty)) {
56
+ return { line, drift: `checkout has moved since this build: now ${describe(now.sha, now.dirty)}` };
57
+ }
58
+ return { line, drift: null };
59
+ }