@cmgmyr/hive 1.9.0 → 1.10.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 +3 -2
- package/claude-plugin/README.md +4 -0
- package/claude-plugin/crew/.claude-plugin/plugin.json +1 -0
- package/claude-plugin/crew/hooks/hooks.json +1 -0
- package/claude-plugin/crew/hooks/register.tsx +82 -0
- package/claude-plugin/crew/model.mjs +138 -0
- package/claude-plugin/crew/types/index.d.ts +18 -0
- package/dist/build-info.json +1 -1
- package/dist/cli.js +55 -23
- package/dist/crew.js +507 -0
- package/dist/dashboard.js +179 -156
- package/dist/db.js +14 -0
- package/dist/harnesses.js +2 -2
- package/dist/heldLabel.js +17 -0
- package/dist/help.js +7 -3
- package/dist/leadMessage.js +29 -8
- package/dist/leadMessageDelivery.js +379 -0
- package/dist/leadState.js +8 -1
- package/dist/portfolio.js +8 -5
- package/dist/projectYml.js +14 -0
- package/dist/queenDashboard.js +30 -13
- package/dist/scheduler.js +160 -37
- package/dist/spawn.js +3 -3
- package/dist/tmux.js +42 -7
- package/dist/tools/agents.js +89 -6
- package/dist/tools/wakes.js +23 -3
- package/dist/transcript.js +135 -2
- package/package.json +1 -1
package/dist/tools/agents.js
CHANGED
|
@@ -10,12 +10,14 @@ import { currentActor, findProjectForDir, getProject, linkedWorktreePrimaryRoot,
|
|
|
10
10
|
import { commandHead, harnessFor, harnessNames, readOnlyExtraArgsAllowed, paneClassifierFor, resolvedCommandPrefix, screenClassifiable, } from "../harnesses.js";
|
|
11
11
|
import { ensureWorkerHooksFile } from "../hooks.js";
|
|
12
12
|
import { activeProfile, allowedAgents, loadProjectYml } from "../projectYml.js";
|
|
13
|
-
import { classifyMiss, LEAD_MESSAGE_THRESHOLD, leadPointerMarker, missMessage, readLeadMessage, renderLeadPointer, senderTag, shortenedSendFailureClause, shortenedSendNote, storeLeadMessage, } from "../leadMessage.js";
|
|
13
|
+
import { classifyMiss, DELIVERY_FIELDS, LEAD_MESSAGE_THRESHOLD, leadPointerMarker, missMessage, readLeadMessage, renderLeadPointer, senderTag, shortenedSendFailureClause, shortenedSendNote, storeLeadMessage, } from "../leadMessage.js";
|
|
14
|
+
import { sendQuietLeadMessage } from "../leadMessageDelivery.js";
|
|
14
15
|
import { run } from "../result.js";
|
|
15
16
|
import { markGoneReported } from "../scheduler.js";
|
|
16
17
|
import { agentIdentityEnv, branchAt, closeAgentRow, currentOwnership, discardOrphanedPane, isReservedAgentName, isRunningLeadActor, killAgentPane, launchAgent, LEAD_KIND, PaneOwnershipLost, paneIdentity, parkAgentRow, reapCodexHomeForClosedAgent, requireStillOwned, releaseParkRow, renameAgent, resumeAgent, storedLaunchOptions, } from "../spawn.js";
|
|
17
18
|
import { COMMAND_KIND, runningCommandRow, stopLine, stopProcess, STOP_REASONS } from "../processes.js";
|
|
18
19
|
import { readContextTokens, readContextFill, resolveTranscriptDir } from "../transcript.js";
|
|
20
|
+
import { leadSessionEnded } from "../leadState.js";
|
|
19
21
|
import { capturePane, captureFinalScreen, DEFAULT_LAYOUT, cancelCopyMode, describePaneChoice, ensureAttached, findUnsafeControlChar, holdsHumanInput, liveTargets, observationFailed, observeRowOwnership, ownershipLiveness, paneCurrentCommand, paneInCopyMode, paneWindow, pollPaneReadiness, rowOwnership, sendText, sessionName, setRemainOnExit, sleep, TEXT_ALLOWED_CONTROL_CHARS, tmux, TmuxTimeoutError, WINDOW_LAYOUTS, } from "../tmux.js";
|
|
20
22
|
import { agentIdParam, agentNameParam, idParam, projectIdParam } from "./params.js";
|
|
21
23
|
import { deriveProvenance, lastLogEvent, lastPermissionMode, reportsAgentStateLog, } from "../stateProvenance.js";
|
|
@@ -356,7 +358,7 @@ function paneField(row, alive) {
|
|
|
356
358
|
return { pane: describePaneChoice(awaitingChoice) };
|
|
357
359
|
}
|
|
358
360
|
function agentSummary(row, snapshot) {
|
|
359
|
-
const alive = summaryLiveness(row, snapshot);
|
|
361
|
+
const { alive, dormant } = row.kind === LEAD_KIND ? leadSummaryLiveness(row, snapshot) : { alive: summaryLiveness(row, snapshot), dormant: null };
|
|
360
362
|
const { state, ...provenance } = deriveProvenance(row, alive);
|
|
361
363
|
return {
|
|
362
364
|
agent_id: row.id,
|
|
@@ -377,7 +379,9 @@ function agentSummary(row, snapshot) {
|
|
|
377
379
|
command: row.command,
|
|
378
380
|
cwd: row.cwd,
|
|
379
381
|
parent_actor_id: row.parent_actor_id,
|
|
382
|
+
todo_id: row.todo_id,
|
|
380
383
|
created_at: row.created_at,
|
|
384
|
+
...(dormant ? { dormant_since: dormant.ended_at } : {}),
|
|
381
385
|
};
|
|
382
386
|
}
|
|
383
387
|
export function contextCheckpointEnv(config) {
|
|
@@ -418,6 +422,9 @@ export function registerAgents(server) {
|
|
|
418
422
|
.describe("Spawn a known harness by name (e.g. \"codex\") instead of a raw command. Ignored when command is also given. Must be in the project's hive.yml agents: list (default: claude only)."),
|
|
419
423
|
extra_args: z.array(z.string()).optional().describe("Extra CLI arguments."),
|
|
420
424
|
read_only: z.boolean().optional().describe("Prevent local file writes and mutating shell commands while allowing Hive MCP tools. Only bare claude/codex executables. Extra args allow codex -c model_reasoning_effort=low|medium|high|xhigh, or claude --effort low|medium|high|xhigh|max; all other arguments are refused."),
|
|
425
|
+
todo_id: idParam
|
|
426
|
+
.optional()
|
|
427
|
+
.describe("The todo this worker is on, recorded as a link (a non-archived todo in the same project). Changes no todo status."),
|
|
421
428
|
cwd: z
|
|
422
429
|
.string()
|
|
423
430
|
.optional()
|
|
@@ -467,6 +474,20 @@ export function registerAgents(server) {
|
|
|
467
474
|
throw new Error(`cwd ${cwd} belongs to project "${cwdProject.name}" (id ${cwdProject.id}), but this spawn resolved to project "${project.name}" (id ${project.id}). ${remedy}`);
|
|
468
475
|
}
|
|
469
476
|
}
|
|
477
|
+
if (args.todo_id !== undefined) {
|
|
478
|
+
const linked = db
|
|
479
|
+
.prepare("SELECT archived_at FROM todos WHERE id = ? AND project_id = ?")
|
|
480
|
+
.get(args.todo_id, project.id);
|
|
481
|
+
if (!linked)
|
|
482
|
+
throw new Error(`todo_id ${args.todo_id} is not a todo in project "${project.name}" (id ${project.id}).`);
|
|
483
|
+
if (linked.archived_at != null)
|
|
484
|
+
throw new Error(`todo_id ${args.todo_id} is archived; unarchive it or link another todo.`);
|
|
485
|
+
}
|
|
486
|
+
const omittedTodoWarning = args.todo_id === undefined && db
|
|
487
|
+
.prepare("SELECT 1 FROM todos WHERE project_id = ? AND status = 'in_progress' AND archived_at IS NULL LIMIT 1")
|
|
488
|
+
.get(project.id)
|
|
489
|
+
? "todo_id was omitted while this project has an in-progress todo; the crew view may infer a link from this worker's comments or pad updates."
|
|
490
|
+
: undefined;
|
|
470
491
|
const name = args.name != null
|
|
471
492
|
? normalizeAgentName(args.name, "name")
|
|
472
493
|
: nextWorkerName(project.id);
|
|
@@ -565,6 +586,7 @@ export function registerAgents(server) {
|
|
|
565
586
|
model: args.model,
|
|
566
587
|
extraArgs: args.extra_args,
|
|
567
588
|
readOnly: args.read_only,
|
|
589
|
+
todoId: args.todo_id,
|
|
568
590
|
retainOnExit: harness.classifiesPaneScreen,
|
|
569
591
|
});
|
|
570
592
|
}
|
|
@@ -621,7 +643,7 @@ export function registerAgents(server) {
|
|
|
621
643
|
}
|
|
622
644
|
}
|
|
623
645
|
const codexInstructions = codexHomeKey ? codexInstructionsPhrase(codexInstructionLayers) : undefined;
|
|
624
|
-
|
|
646
|
+
const receipt = {
|
|
625
647
|
agent_id: agentId,
|
|
626
648
|
...(args.read_only ? { read_only: true } : {}),
|
|
627
649
|
actor_id: actorId,
|
|
@@ -670,6 +692,11 @@ export function registerAgents(server) {
|
|
|
670
692
|
"Wakes aimed at this worker are refused for the same reason.",
|
|
671
693
|
}),
|
|
672
694
|
};
|
|
695
|
+
if (omittedTodoWarning) {
|
|
696
|
+
const existingNote = typeof receipt.note === "string" ? receipt.note : "";
|
|
697
|
+
receipt.note = existingNote ? `${existingNote} ${omittedTodoWarning}` : omittedTodoWarning;
|
|
698
|
+
}
|
|
699
|
+
return receipt;
|
|
673
700
|
}));
|
|
674
701
|
server.registerTool("agent_resume", {
|
|
675
702
|
description: "Resume a CLOSED claude or codex worker from its recorded session id (claude --resume / codex resume): a fresh pane, the same actor_id, and the worker's full prior context. Addressed by name or agent_id among closed agents (agent_list(include_closed: true)). Send it its next instruction with agent_send once resumed - this tool does not.",
|
|
@@ -1126,7 +1153,7 @@ export function registerAgents(server) {
|
|
|
1126
1153
|
};
|
|
1127
1154
|
}));
|
|
1128
1155
|
server.registerTool("agent_send", {
|
|
1129
|
-
description: "Type into an agent's terminal, addressed by name (or agent_id). text of any shape is prefixed with the sender tag, delivered as one bracketed paste and submitted with Enter unless submit=false. ONE EXCEPTION: text over 300 characters sent to a LEAD by anyone who is not that lead is stored and delivered as a one-line pointer instead, because a lead's pane is a human's own window; the receipt says so and names agent_message_get for the full text. Worker-bound text is never shortened at any length. Alternatively pass keys (tmux key names like Escape, C-c, Enter). wait_ms (250-10000) returns the terminal tail after sending. A claude worker is already briefed by agent_spawn. A worker whose screen hive cannot classify is REFUSED on the text path entirely (its brief is at the spawn receipt's brief_path); keys still reaches it. A pane in tmux copy mode is REFUSED too, and retriably: tmux clears its bracketed-paste flag there, so the paste would lose its markers and the Enter would be eaten - leave copy mode (or agent_send(keys: [\"-X\", \"cancel\"]) to cancel it deliberately) and send again.",
|
|
1156
|
+
description: "Type into an agent's terminal, addressed by name (or agent_id). text of any shape is prefixed with the sender tag, delivered as one bracketed paste and submitted with Enter unless submit=false. ONE EXCEPTION: text over 300 characters sent to a LEAD by anyone who is not that lead is stored and delivered as a one-line pointer instead, because a lead's pane is a human's own window; the receipt says so and names agent_message_get for the full text. Worker-bound text is never shortened at any length. With quiet_messaging on, a worker's submitted text to a Claude lead is stored with a message id and posted over the lead's messaging socket instead of typed (the same pointer when over 300 characters): the receipt says pending until the lead's prompt confirms it, hive types it into the pane once if no confirmation comes, and the socket post skips the pane checks below; do not resend a pending send. Alternatively pass keys (tmux key names like Escape, C-c, Enter). wait_ms (250-10000) returns the terminal tail after sending. A claude worker is already briefed by agent_spawn. A worker whose screen hive cannot classify is REFUSED on the text path entirely (its brief is at the spawn receipt's brief_path); keys still reaches it. A pane in tmux copy mode is REFUSED too, and retriably: tmux clears its bracketed-paste flag there, so the paste would lose its markers and the Enter would be eaten - leave copy mode (or agent_send(keys: [\"-X\", \"cancel\"]) to cancel it deliberately) and send again.",
|
|
1130
1157
|
annotations: {
|
|
1131
1158
|
readOnlyHint: false,
|
|
1132
1159
|
destructiveHint: true,
|
|
@@ -1151,6 +1178,7 @@ export function registerAgents(server) {
|
|
|
1151
1178
|
let outgoing = args.text ?? "";
|
|
1152
1179
|
const tag = senderTag(project.id, currentActor());
|
|
1153
1180
|
let shortened = null;
|
|
1181
|
+
let boxBefore = null;
|
|
1154
1182
|
const withShortened = (receipt) => shortened === null
|
|
1155
1183
|
? receipt
|
|
1156
1184
|
: {
|
|
@@ -1183,6 +1211,32 @@ export function registerAgents(server) {
|
|
|
1183
1211
|
"turning a text call into a keys call. Tab and newline are the only control characters " +
|
|
1184
1212
|
'allowed. To send an actual keystroke on purpose, use keys instead (e.g. keys: ["C-c"]).');
|
|
1185
1213
|
}
|
|
1214
|
+
const quiet = await sendQuietLeadMessage({
|
|
1215
|
+
projectId: project.id,
|
|
1216
|
+
fromActor: currentActor(),
|
|
1217
|
+
target: agent,
|
|
1218
|
+
text: args.text,
|
|
1219
|
+
submit: args.submit !== false,
|
|
1220
|
+
});
|
|
1221
|
+
if (quiet !== null) {
|
|
1222
|
+
if (quiet.sent) {
|
|
1223
|
+
confirmQueenWrite("agent_send", project.id, args, { sent: true, agent_id: agent.id }, auditIdentity);
|
|
1224
|
+
}
|
|
1225
|
+
if (args.wait_ms == null)
|
|
1226
|
+
return quiet;
|
|
1227
|
+
await sleep(Math.min(Math.max(args.wait_ms, 250), 10000));
|
|
1228
|
+
try {
|
|
1229
|
+
if (observeOwnership(agent) !== "live")
|
|
1230
|
+
throw new Error("not live");
|
|
1231
|
+
return { ...quiet, tail: capturePane(target, 15) };
|
|
1232
|
+
}
|
|
1233
|
+
catch {
|
|
1234
|
+
return {
|
|
1235
|
+
...quiet,
|
|
1236
|
+
tail_note: "The terminal tail could not be read after the wait (the pane may have died or changed owner).",
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1186
1240
|
if (!screenClassifiable(agent.command)) {
|
|
1187
1241
|
return {
|
|
1188
1242
|
agent_id: agent.id,
|
|
@@ -1235,7 +1289,9 @@ export function registerAgents(server) {
|
|
|
1235
1289
|
};
|
|
1236
1290
|
}
|
|
1237
1291
|
if (submitting) {
|
|
1238
|
-
const
|
|
1292
|
+
const snapshot = readInputBox(sendClassifier, target);
|
|
1293
|
+
const box = snapshot?.box ?? null;
|
|
1294
|
+
boxBefore = inputBoxBefore(snapshot);
|
|
1239
1295
|
if (holdsHumanInput(box)) {
|
|
1240
1296
|
return {
|
|
1241
1297
|
agent_id: agent.id,
|
|
@@ -1319,12 +1375,13 @@ export function registerAgents(server) {
|
|
|
1319
1375
|
sent: true,
|
|
1320
1376
|
...tailField,
|
|
1321
1377
|
...inputBoxField(agent.command, target),
|
|
1378
|
+
...(boxBefore === null ? {} : { input_box_before: boxBefore }),
|
|
1322
1379
|
});
|
|
1323
1380
|
}
|
|
1324
1381
|
return withShortened({ agent_id: agent.id, name: agent.name, sent: true });
|
|
1325
1382
|
}));
|
|
1326
1383
|
server.registerTool("agent_message_get", {
|
|
1327
|
-
description: "Read one agent-to-lead message in full, by the id in a \"[hive:worker NAME] [message #N ...]\" pointer line. hive stores a message here
|
|
1384
|
+
description: "Read one agent-to-lead message in full, by the id in a \"[hive:worker NAME] [message #N ...]\" pointer line. hive stores a message here when it shortens one (text over 300 characters sent to a lead by someone who is not that lead) and, with quiet_messaging on, for every submitted worker send to a Claude lead, whose result then also carries its delivery state (delivery_status, delivery_method, socket_attempt_at, confirmed_at, fallback_claimed_at, typed_at, delivery_note and the accepted target). Every other send is typed with its sender tag and stores nothing, so there is no id to read. Messages are pruned after 7 days, and a lookup for a pruned id says so rather than reporting it missing.",
|
|
1328
1385
|
annotations: {
|
|
1329
1386
|
readOnlyHint: false,
|
|
1330
1387
|
destructiveHint: false,
|
|
@@ -1349,6 +1406,9 @@ export function registerAgents(server) {
|
|
|
1349
1406
|
chars: row.text.length,
|
|
1350
1407
|
created_at: row.created_at,
|
|
1351
1408
|
text: row.text,
|
|
1409
|
+
...(row.delivery_status === null
|
|
1410
|
+
? {}
|
|
1411
|
+
: Object.fromEntries(DELIVERY_FIELDS.map((field) => [field, row[field]]))),
|
|
1352
1412
|
};
|
|
1353
1413
|
}));
|
|
1354
1414
|
server.registerTool("agent_output", {
|
|
@@ -1528,3 +1588,26 @@ export function registerAgents(server) {
|
|
|
1528
1588
|
};
|
|
1529
1589
|
}));
|
|
1530
1590
|
}
|
|
1591
|
+
function inputBoxBefore(snapshot) {
|
|
1592
|
+
if (snapshot === null)
|
|
1593
|
+
return { state: "unreadable" };
|
|
1594
|
+
const { box, rows, promptSgr } = snapshot;
|
|
1595
|
+
const sgr = promptSgr ? { prompt_sgr: promptSgr } : {};
|
|
1596
|
+
const withRows = rows === undefined ? {} : { rows };
|
|
1597
|
+
if (box === null)
|
|
1598
|
+
return { state: "not_found", ...withRows, ...sgr };
|
|
1599
|
+
if (box.state === "ghost")
|
|
1600
|
+
return { state: "ghost", ...sgr };
|
|
1601
|
+
return { state: box.state, text: box.text, ...withRows, ...sgr };
|
|
1602
|
+
}
|
|
1603
|
+
function readInputBox(classifier, target) {
|
|
1604
|
+
if (classifier.inputBoxSnapshot)
|
|
1605
|
+
return classifier.inputBoxSnapshot(target);
|
|
1606
|
+
const box = classifier.inputBoxState(target);
|
|
1607
|
+
return box === null ? null : { box };
|
|
1608
|
+
}
|
|
1609
|
+
function leadSummaryLiveness(row, snapshot) {
|
|
1610
|
+
const ownership = row.status === "running" ? rowOwnership(row, snapshot) : null;
|
|
1611
|
+
const alive = ownership === null ? false : ownershipLiveness(ownership);
|
|
1612
|
+
return { alive, dormant: ownership === null ? null : leadSessionEnded(row, ownership) };
|
|
1613
|
+
}
|
package/dist/tools/wakes.js
CHANGED
|
@@ -112,9 +112,23 @@ function deliveryState(t, hasChannel) {
|
|
|
112
112
|
? "unconfirmed_busy"
|
|
113
113
|
: "unconfirmed",
|
|
114
114
|
delivery_method: t.delivery_method,
|
|
115
|
+
delivered_by: t.delivered_by,
|
|
115
116
|
socket_attempt_at: t.socket_attempt_at,
|
|
116
117
|
};
|
|
117
118
|
}
|
|
119
|
+
function deliveredByNote(t) {
|
|
120
|
+
if (t.delivery_method === null)
|
|
121
|
+
return null;
|
|
122
|
+
if (t.delivered_by === null)
|
|
123
|
+
return "delivered by a hive build older than this field";
|
|
124
|
+
if (t.repeat_every_ms === null)
|
|
125
|
+
return null;
|
|
126
|
+
const firing = t.delivered_by.match(/, firing #(\d+)$/);
|
|
127
|
+
return firing !== null && Number(firing[1]) !== t.fire_count
|
|
128
|
+
? `firing #${t.fire_count} was claimed by a hive build older than this field; ` +
|
|
129
|
+
"that build types it into the pane and does not record delivered_by"
|
|
130
|
+
: null;
|
|
131
|
+
}
|
|
118
132
|
export const truncateBody = (body) => body.length > 120 ? `${cutToUnitBudget(body, 120)}…` : body;
|
|
119
133
|
function rejectUnsafeBody(body) {
|
|
120
134
|
const bad = findUnsafeControlChar(body, TEXT_ALLOWED_CONTROL_CHARS);
|
|
@@ -486,8 +500,13 @@ export function registerWakes(server) {
|
|
|
486
500
|
server.registerTool("wake_get", {
|
|
487
501
|
description: "Read one wake-up by id, in this project, with its UNTRUNCATED body. wake_list truncates " +
|
|
488
502
|
"body at 120 chars; use this to see exactly what a wake will say, or to confirm what " +
|
|
489
|
-
"wake_update just changed. Its delivery state includes delivery_method (socket, pty or " +
|
|
490
|
-
"pty-after-socket-timeout) and
|
|
503
|
+
"wake_update just changed. Its delivery state includes delivery_method (socket, socket-repeating, pty or " +
|
|
504
|
+
"pty-after-socket-timeout), delivered_by (the pid and build of the server that last set the delivery method, " +
|
|
505
|
+
"plus the firing number for repeating wakes), " +
|
|
506
|
+
"and socket_attempt_at, set when a wake was posted to a Claude lead's socket; " +
|
|
507
|
+
"for a repeating wake both describe its current firing (fire_count). An older repeat claim is " +
|
|
508
|
+
"identified in delivered_by_note as a claim; the older build types the firing into the pane " +
|
|
509
|
+
"without recording delivered_by.",
|
|
491
510
|
annotations: {
|
|
492
511
|
readOnlyHint: false,
|
|
493
512
|
destructiveHint: false,
|
|
@@ -511,6 +530,7 @@ export function registerWakes(server) {
|
|
|
511
530
|
repeating: t.repeat_every_ms != null,
|
|
512
531
|
fire_count: t.fire_count,
|
|
513
532
|
cancelled_at: t.cancelled_at,
|
|
533
|
+
delivered_by_note: deliveredByNote(t),
|
|
514
534
|
first_held_at: t.first_held_at,
|
|
515
535
|
...deliveryState(t, hasChannel),
|
|
516
536
|
};
|
|
@@ -626,7 +646,7 @@ export function registerWakes(server) {
|
|
|
626
646
|
server.registerTool("wake_list", {
|
|
627
647
|
description: "List pending wake-ups in this project, plus recently_delivered: the last " +
|
|
628
648
|
`${RECENTLY_FIRED_LIMIT} one-shot wakes that have already fired, with their delivery ` +
|
|
629
|
-
"state (typed_at, held_at/held_reason, confirmation, delivery_method, socket_attempt_at). A one-shot wake leaves the " +
|
|
649
|
+
"state (typed_at, held_at/held_reason, confirmation, delivery_method, delivered_by, socket_attempt_at). A one-shot wake leaves the " +
|
|
630
650
|
"pending list the moment it fires; recently_delivered is where to check whether it " +
|
|
631
651
|
"was actually typed and, if its target has a confirmation channel, acknowledged.",
|
|
632
652
|
annotations: {
|
package/dist/transcript.js
CHANGED
|
@@ -35,6 +35,9 @@ function usageTokens(record) {
|
|
|
35
35
|
return tokenNumber(sum) && sum > 0 ? sum : null;
|
|
36
36
|
}
|
|
37
37
|
function tailRecords(path) {
|
|
38
|
+
return tailRead(path).records;
|
|
39
|
+
}
|
|
40
|
+
function tailRead(path) {
|
|
38
41
|
let fd;
|
|
39
42
|
try {
|
|
40
43
|
fd = openSync(path, "r");
|
|
@@ -54,10 +57,10 @@ function tailRecords(path) {
|
|
|
54
57
|
}
|
|
55
58
|
catch { }
|
|
56
59
|
}
|
|
57
|
-
return records;
|
|
60
|
+
return { records, clipped: start > 0 };
|
|
58
61
|
}
|
|
59
62
|
catch {
|
|
60
|
-
return [];
|
|
63
|
+
return { records: [], clipped: false };
|
|
61
64
|
}
|
|
62
65
|
finally {
|
|
63
66
|
if (fd !== undefined)
|
|
@@ -124,3 +127,133 @@ export function readContextTokens(cwd, sessionId, explicitPath = "") {
|
|
|
124
127
|
return null;
|
|
125
128
|
return claudeTokens(tailRecords(transcriptPath(cwd, sessionId, explicitPath)));
|
|
126
129
|
}
|
|
130
|
+
export const RECENT_TOOL_CALLS = 8;
|
|
131
|
+
export const SKILL_TOOL = "Skill";
|
|
132
|
+
export function lastComponent(name) {
|
|
133
|
+
return name.split(/__|[.:/]/).filter(Boolean).pop() ?? name;
|
|
134
|
+
}
|
|
135
|
+
function timestamp(record) {
|
|
136
|
+
return typeof record.timestamp === "string" && record.timestamp !== "" ? record.timestamp : null;
|
|
137
|
+
}
|
|
138
|
+
function reviewMarker(texts, at, skills) {
|
|
139
|
+
for (const skill of skills) {
|
|
140
|
+
const invocation = new RegExp(`^\\s*\\$${skill.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w-])`, "m");
|
|
141
|
+
if (texts.some((text) => typeof text === "string" && invocation.test(text))) {
|
|
142
|
+
return [{ name: SKILL_TOOL, input: { skill }, at }];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
function claudeToolCalls(record, options) {
|
|
148
|
+
if (record.type !== "assistant")
|
|
149
|
+
return [];
|
|
150
|
+
const content = object(record.message)?.content;
|
|
151
|
+
if (!Array.isArray(content))
|
|
152
|
+
return [];
|
|
153
|
+
const at = timestamp(record);
|
|
154
|
+
const calls = [];
|
|
155
|
+
for (const part of content) {
|
|
156
|
+
const block = object(part);
|
|
157
|
+
if (!block)
|
|
158
|
+
continue;
|
|
159
|
+
if (block.type === "tool_use" && typeof block.name === "string") {
|
|
160
|
+
calls.push({ name: block.name, input: object(block.input) ?? {}, at });
|
|
161
|
+
}
|
|
162
|
+
else if (block.type === "text") {
|
|
163
|
+
calls.push(...reviewMarker([block.text], at, options.reviewSkills ?? []));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return calls;
|
|
167
|
+
}
|
|
168
|
+
function stringLiteral(text) {
|
|
169
|
+
const quote = text[0];
|
|
170
|
+
if (quote !== '"' && quote !== "'")
|
|
171
|
+
return null;
|
|
172
|
+
const json = quote === '"' ? text : `"${text.slice(1, -1).replace(/\\'/g, "'").replace(/(\\.)|"/g, (m, esc) => esc ?? '\\"')}"`;
|
|
173
|
+
try {
|
|
174
|
+
const value = JSON.parse(json);
|
|
175
|
+
return typeof value === "string" ? value : null;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function execInnerCalls(source, at) {
|
|
182
|
+
const marks = [...source.matchAll(/\btools\.([A-Za-z_]\w*)\s*\(/g)];
|
|
183
|
+
if (marks.length === 0)
|
|
184
|
+
return null;
|
|
185
|
+
const calls = [];
|
|
186
|
+
marks.forEach((mark, i) => {
|
|
187
|
+
const name = lastComponent(mark[1]);
|
|
188
|
+
if (name === "write_stdin")
|
|
189
|
+
return;
|
|
190
|
+
if (name === "exec_command") {
|
|
191
|
+
const span = source.slice(mark.index + mark[0].length, marks[i + 1]?.index ?? source.length);
|
|
192
|
+
const cmd = /["']?\bcmd["']?\s*:\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/.exec(span);
|
|
193
|
+
const command = cmd ? stringLiteral(cmd[1]) : null;
|
|
194
|
+
if (command !== null)
|
|
195
|
+
calls.push({ name: "Bash", input: { command }, at });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
calls.push({ name, input: {}, at });
|
|
199
|
+
});
|
|
200
|
+
return calls;
|
|
201
|
+
}
|
|
202
|
+
function codexToolCalls(record, options) {
|
|
203
|
+
const payload = object(record.payload);
|
|
204
|
+
if (record.type !== "response_item" || !payload)
|
|
205
|
+
return [];
|
|
206
|
+
const at = timestamp(record);
|
|
207
|
+
if (payload.type === "message") {
|
|
208
|
+
if (payload.role !== "assistant" || !Array.isArray(payload.content))
|
|
209
|
+
return [];
|
|
210
|
+
return reviewMarker(payload.content.map((part) => object(part)?.text), at, options.reviewSkills ?? []);
|
|
211
|
+
}
|
|
212
|
+
if (typeof payload.name !== "string")
|
|
213
|
+
return [];
|
|
214
|
+
const name = lastComponent(payload.name);
|
|
215
|
+
if (payload.type === "custom_tool_call") {
|
|
216
|
+
if (name === "exec" && typeof payload.input === "string") {
|
|
217
|
+
const inner = execInnerCalls(payload.input, at);
|
|
218
|
+
if (inner)
|
|
219
|
+
return inner;
|
|
220
|
+
}
|
|
221
|
+
return [{ name, input: typeof payload.input === "string" ? { input: payload.input } : {}, at }];
|
|
222
|
+
}
|
|
223
|
+
if (payload.type !== "function_call")
|
|
224
|
+
return [];
|
|
225
|
+
if (name === "write_stdin" || name === "sleep")
|
|
226
|
+
return [];
|
|
227
|
+
let args = {};
|
|
228
|
+
if (typeof payload.arguments === "string") {
|
|
229
|
+
try {
|
|
230
|
+
args = object(JSON.parse(payload.arguments)) ?? {};
|
|
231
|
+
}
|
|
232
|
+
catch { }
|
|
233
|
+
}
|
|
234
|
+
if (name === "exec_command")
|
|
235
|
+
return typeof args.cmd === "string" ? [{ name: "Bash", input: { command: args.cmd }, at }] : [];
|
|
236
|
+
return [{ name, input: args, at }];
|
|
237
|
+
}
|
|
238
|
+
export function readRecentToolCalls(kind, worker, options = {}) {
|
|
239
|
+
const path = worker.transcript_path || (kind === "claude" && worker.session_id
|
|
240
|
+
? join(transcriptDir(worker.cwd), `${worker.session_id}.jsonl`) : "");
|
|
241
|
+
if (!path)
|
|
242
|
+
return { calls: [], truncated: false };
|
|
243
|
+
const { records, clipped } = tailRead(path);
|
|
244
|
+
const newestFirst = [];
|
|
245
|
+
let capped = false;
|
|
246
|
+
for (const record of records) {
|
|
247
|
+
const calls = (kind === "claude" ? claudeToolCalls : codexToolCalls)(record, options);
|
|
248
|
+
for (let i = calls.length - 1; i >= 0; i--) {
|
|
249
|
+
if (newestFirst.length === RECENT_TOOL_CALLS) {
|
|
250
|
+
capped = true;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
newestFirst.push(calls[i]);
|
|
254
|
+
}
|
|
255
|
+
if (capped)
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
return { calls: newestFirst.reverse(), truncated: clipped || capped };
|
|
259
|
+
}
|
package/package.json
CHANGED