@cmgmyr/hive 1.9.0 → 1.9.1
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 +2 -2
- package/dist/build-info.json +1 -1
- package/dist/cli.js +2 -2
- package/dist/db.js +13 -0
- package/dist/harnesses.js +2 -2
- package/dist/help.js +7 -3
- package/dist/leadMessage.js +29 -8
- package/dist/leadMessageDelivery.js +379 -0
- package/dist/scheduler.js +160 -37
- package/dist/tmux.js +42 -7
- package/dist/tools/agents.js +56 -4
- package/dist/tools/wakes.js +23 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Run a crew of Claude Code and Codex workers from one lead session: visible tmux
|
|
|
5
5
|
|
|
6
6
|
- Visible workers: each one is a real tmux pane you can read and type into.
|
|
7
7
|
- Exact state: workers report it through their own CLI's hooks, so nothing polls.
|
|
8
|
-
- One local SQLite store: no daemon, and
|
|
8
|
+
- One local SQLite store: no daemon. The MCP server, hooks, and scheduler make no outbound requests; the CLI contacts npm only to check for updates, and upgrades can download ([details](docs/security.md)).
|
|
9
9
|
|
|
10
10
|

|
|
11
11
|
|
|
@@ -32,7 +32,7 @@ Run `cd ~/Code/your-project && hive`. It is shorthand for `hive lead`, and it op
|
|
|
32
32
|
|
|
33
33
|
## How it works
|
|
34
34
|
|
|
35
|
-
Each Claude Code session runs its own `hive` MCP server over stdio, and every instance reads and writes one SQLite database (WAL mode) at `~/.hive/hive.db`, so every session sees the same state. There is no daemon and
|
|
35
|
+
Each Claude Code session runs its own `hive` MCP server over stdio, and every instance reads and writes one SQLite database (WAL mode) at `~/.hive/hive.db`, so every session sees the same state. There is no daemon. The MCP server, hooks, and scheduler make no outbound requests; the CLI contacts npm only to check for updates, and upgrades can download ([details](docs/security.md)). State is scoped to a project (a directory), resolved from the working directory; a lead spawns workers into tmux panes locked to that project.
|
|
36
36
|
|
|
37
37
|
## Why not subagents?
|
|
38
38
|
|
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.9.
|
|
1
|
+
{"version":"1.9.1","sha":"2056b89","dirty":false,"build_id":"6b9bbe7ee1cda1fc2700518664c980aac9692d27caead18970553a3f6b3cbae9"}
|
package/dist/cli.js
CHANGED
|
@@ -745,7 +745,7 @@ const HIVE_YML_TEMPLATE = `# hive project config. Read by \`hive lead\` from the
|
|
|
745
745
|
|
|
746
746
|
# dashboard: true # write .hive/dashboard.html (default: false)
|
|
747
747
|
|
|
748
|
-
# quiet_messaging: true # wakes reach a claude lead as one row (default: false; restart the lead)
|
|
748
|
+
# quiet_messaging: true # wakes and worker messages reach a claude lead as one row (default: false; restart the lead)
|
|
749
749
|
|
|
750
750
|
# vars: # substituted into the profile runbook
|
|
751
751
|
# repo: owner/name # {{repo}}
|
|
@@ -3376,7 +3376,7 @@ function cmdPad(argv) {
|
|
|
3376
3376
|
process.stdout.write(withTrailingNewline(pad.content));
|
|
3377
3377
|
}
|
|
3378
3378
|
process.stdout.on("error", (error) => {
|
|
3379
|
-
if (error.code === "EPIPE")
|
|
3379
|
+
if (error.code === "EPIPE" || error.code === "ENOTCONN")
|
|
3380
3380
|
process.exit(0);
|
|
3381
3381
|
throw error;
|
|
3382
3382
|
});
|
package/dist/db.js
CHANGED
|
@@ -415,6 +415,19 @@ ALTER TABLE agents ADD COLUMN claude_messaging_pane_pid TEXT NOT NULL DEFAULT ''
|
|
|
415
415
|
ALTER TABLE wakes ADD COLUMN delivery_method TEXT;
|
|
416
416
|
ALTER TABLE wakes ADD COLUMN socket_attempt_at TEXT;
|
|
417
417
|
ALTER TABLE wakes ADD COLUMN socket_delivery_note TEXT;
|
|
418
|
+
`,
|
|
419
|
+
`ALTER TABLE wakes ADD COLUMN delivered_by TEXT;`,
|
|
420
|
+
`
|
|
421
|
+
ALTER TABLE agent_messages ADD COLUMN delivery_status TEXT;
|
|
422
|
+
ALTER TABLE agent_messages ADD COLUMN delivery_method TEXT;
|
|
423
|
+
ALTER TABLE agent_messages ADD COLUMN socket_attempt_at TEXT;
|
|
424
|
+
ALTER TABLE agent_messages ADD COLUMN confirmed_at TEXT;
|
|
425
|
+
ALTER TABLE agent_messages ADD COLUMN fallback_claimed_at TEXT;
|
|
426
|
+
ALTER TABLE agent_messages ADD COLUMN typed_at TEXT;
|
|
427
|
+
ALTER TABLE agent_messages ADD COLUMN delivery_note TEXT;
|
|
428
|
+
ALTER TABLE agent_messages ADD COLUMN target_identity TEXT;
|
|
429
|
+
ALTER TABLE agent_messages ADD COLUMN sender_tag TEXT;
|
|
430
|
+
CREATE INDEX idx_agent_messages_delivery ON agent_messages(delivery_status, id) WHERE delivery_status IS NOT NULL;
|
|
418
431
|
`,
|
|
419
432
|
];
|
|
420
433
|
export function storeSchemaAhead(database) {
|
package/dist/harnesses.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { claudeTrailerView, codexInputBoxState, codexTrailerView, codexPaneChoiceCheck, codexPaneHasInputBox, inputBoxState, paneChoiceCheck, paneHasInputBox, } from "./tmux.js";
|
|
1
|
+
import { claudeTrailerView, codexInputBoxState, codexTrailerView, codexPaneChoiceCheck, codexPaneHasInputBox, inputBoxSnapshot, inputBoxState, paneChoiceCheck, paneHasInputBox, } from "./tmux.js";
|
|
2
2
|
export function commandHead(command) {
|
|
3
3
|
return command.trim().split(/\s+/)[0] ?? "";
|
|
4
4
|
}
|
|
@@ -52,7 +52,7 @@ const claudeHarness = {
|
|
|
52
52
|
supportsResume: true,
|
|
53
53
|
supportsRename: true,
|
|
54
54
|
classifiesPaneScreen: true,
|
|
55
|
-
paneClassifier: { choiceCheck: paneChoiceCheck, inputBoxState, hasInputBox: paneHasInputBox, trailerView: claudeTrailerView },
|
|
55
|
+
paneClassifier: { choiceCheck: paneChoiceCheck, inputBoxState, inputBoxSnapshot, hasInputBox: paneHasInputBox, trailerView: claudeTrailerView },
|
|
56
56
|
hasScopes: true,
|
|
57
57
|
needsHome: false,
|
|
58
58
|
// "--" so a message that starts with a dash is never read as a flag.
|
package/dist/help.js
CHANGED
|
@@ -148,9 +148,13 @@ runbook (fork it first), not a pad write.`,
|
|
|
148
148
|
Text is prefixed with the sender tag.
|
|
149
149
|
Text over 300 chars sent to a LEAD by anyone but that lead is stored and
|
|
150
150
|
delivered as a one-line pointer, so the human's own pane stays readable;
|
|
151
|
-
the receipt says shortened and names the message id.
|
|
152
|
-
|
|
153
|
-
|
|
151
|
+
the receipt says shortened and names the message id. With quiet_messaging
|
|
152
|
+
on, a worker's submitted text to a Claude lead is stored with an id and
|
|
153
|
+
posted to the lead's messaging socket instead; the receipt says pending
|
|
154
|
+
until the lead's prompt confirms it, and hive types it into the pane once
|
|
155
|
+
if nothing does. Do not resend a pending send.
|
|
156
|
+
agent_message_get(message_id) — the full text behind a pointer line, plus
|
|
157
|
+
the delivery state of a quiet message. Kept 7 days; a lookup for a pruned id says it expired, not that it is missing.
|
|
154
158
|
agent_output(name|agent_id, lines?) — read the rendered terminal.
|
|
155
159
|
agent_status(name|agent_id, include_brief?) — liveness, current command,
|
|
156
160
|
short tail, and the path to the brief this worker was given
|
package/dist/leadMessage.js
CHANGED
|
@@ -14,6 +14,17 @@ export const MESSAGE_RETENTION = "-7 days";
|
|
|
14
14
|
// direction. Far above any observed rate, and lower than agent_state_log's 20,000 because a row here
|
|
15
15
|
// holds unbounded message text rather than a hook payload.
|
|
16
16
|
export const MESSAGE_MAX_ROWS = 5_000;
|
|
17
|
+
export const DELIVERY_FIELDS = [
|
|
18
|
+
"delivery_status",
|
|
19
|
+
"delivery_method",
|
|
20
|
+
"socket_attempt_at",
|
|
21
|
+
"confirmed_at",
|
|
22
|
+
"fallback_claimed_at",
|
|
23
|
+
"typed_at",
|
|
24
|
+
"delivery_note",
|
|
25
|
+
"target_identity",
|
|
26
|
+
"sender_tag",
|
|
27
|
+
];
|
|
17
28
|
function senderName(projectId, actorId) {
|
|
18
29
|
const row = db
|
|
19
30
|
.prepare(`SELECT name FROM agents WHERE project_id = ? AND actor_id = ?
|
|
@@ -44,7 +55,7 @@ export function storeLeadMessage(projectId, fromActor, toAgentId, text) {
|
|
|
44
55
|
}
|
|
45
56
|
export function readLeadMessage(projectId, messageId) {
|
|
46
57
|
return db
|
|
47
|
-
.prepare(`SELECT id, from_actor, from_name, to_agent_id, text, created_at
|
|
58
|
+
.prepare(`SELECT id, from_actor, from_name, to_agent_id, text, created_at, ${DELIVERY_FIELDS.join(", ")}
|
|
48
59
|
FROM agent_messages WHERE id = ? AND project_id = ?`)
|
|
49
60
|
.get(messageId, projectId);
|
|
50
61
|
}
|
|
@@ -75,17 +86,27 @@ export function missMessage(messageId, kind) {
|
|
|
75
86
|
// also covers format characters. The head comes from outside, and a raw control byte in it reaches tmux
|
|
76
87
|
// as a keystroke rather than as text, submitting this pointer early and splitting it. The sender's name
|
|
77
88
|
// is flattened by senderTag when it builds the tag ahead of this marker - the tag is who, this is what.
|
|
78
|
-
export function leadPointerMarker(id, text) {
|
|
79
|
-
return `[message #${id}, ${text.length} chars]`;
|
|
89
|
+
export function leadPointerMarker(id, text, redelivered = false) {
|
|
90
|
+
return `[message #${id}, ${text.length} chars${redelivered ? ", re-delivered" : ""}]`;
|
|
80
91
|
}
|
|
81
|
-
|
|
92
|
+
const redeliveredLead = (id, redelivered) => redelivered ? ` If you already handled message #${id}, ignore this.` : "";
|
|
93
|
+
export function renderLeadPointer(id, text, tag, redelivered = false) {
|
|
82
94
|
const flat = flatten(text);
|
|
83
95
|
const head = flat.length > HEAD_BUDGET ? cutToUnitBudget(flat, HEAD_BUDGET) + ELLIPSIS : flat;
|
|
84
|
-
return `${tag}${leadPointerMarker(id, text)} ${head} agent_message_get(${id}) for the full text.`;
|
|
96
|
+
return `${tag}${leadPointerMarker(id, text, redelivered)}${redeliveredLead(id, redelivered)} ${head} agent_message_get(${id}) for the full text.`;
|
|
85
97
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
98
|
+
// A short message carries its id too, so a quiet send has a marker its confirmation can match.
|
|
99
|
+
export function renderLeadMessage(id, text, tag, redelivered = false) {
|
|
100
|
+
return text.length > LEAD_MESSAGE_THRESHOLD
|
|
101
|
+
? renderLeadPointer(id, text, tag, redelivered)
|
|
102
|
+
: `${tag}${leadPointerMarker(id, text, redelivered)}${redeliveredLead(id, redelivered)} ${text}`;
|
|
103
|
+
}
|
|
104
|
+
export function shortenedSendNote(id, deliveredChars, channel = "pane") {
|
|
105
|
+
return (`Over ${LEAD_MESSAGE_THRESHOLD} characters to a LEAD, so ` +
|
|
106
|
+
(channel === "pane"
|
|
107
|
+
? `that pane got a ${deliveredChars}-character pointer instead of this text`
|
|
108
|
+
: `hive delivers a ${deliveredChars}-character pointer instead of this text, over the lead's messaging socket or, failing that, typed once into its pane`) +
|
|
109
|
+
`: a lead's pane is a human's own window and hive keeps it quiet. Nothing ` +
|
|
89
110
|
`was lost - the full text is stored as message ${id} and the pointer names agent_message_get(${id}) - ` +
|
|
90
111
|
`but the lead reads the rest only if it chooses to. Put whatever must be ACTED on in the first ` +
|
|
91
112
|
`${HEAD_BUDGET} characters, or on the todo, where it is durable. Worker-bound sends are never shortened.`);
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { dataDir, db } from "./db.js";
|
|
3
|
+
import { CROSS_SESSION_CLOSE, postClaudeWake, senderAddress } from "./claudeWake.js";
|
|
4
|
+
import { harnessFor, paneClassifierFor } from "./harnesses.js";
|
|
5
|
+
import { LEAD_MESSAGE_THRESHOLD, renderLeadMessage, senderTag, shortenedSendNote, storeLeadMessage, } from "./leadMessage.js";
|
|
6
|
+
import { readLeadTurnState } from "./leadState.js";
|
|
7
|
+
import { loadProjectYml } from "./projectYml.js";
|
|
8
|
+
import { currentOwnership, PaneOwnershipLost, paneIdentity, requireStillOwned } from "./spawn.js";
|
|
9
|
+
import { holdsHumanInput, paneInCopyMode, sendText, sleep, TmuxTimeoutError, } from "./tmux.js";
|
|
10
|
+
export const SOCKET_MESSAGE_FOOTER = "Automated hive worker message. Do not reply to this socket sender.";
|
|
11
|
+
export const MESSAGE_STATUS = {
|
|
12
|
+
socketPending: "socket-pending",
|
|
13
|
+
fallbackPending: "fallback-pending",
|
|
14
|
+
fallbackClaimed: "fallback-claimed",
|
|
15
|
+
complete: "complete",
|
|
16
|
+
failed: "failed",
|
|
17
|
+
};
|
|
18
|
+
export const MESSAGE_METHOD = {
|
|
19
|
+
socket: "socket",
|
|
20
|
+
pty: "pty",
|
|
21
|
+
ptyAfterSocketTimeout: "pty-after-socket-timeout",
|
|
22
|
+
};
|
|
23
|
+
const PANE_LEASE_TTL_SECONDS = 6;
|
|
24
|
+
const PANE_LEASE_SETTLE_MS = 500;
|
|
25
|
+
const CONFIRM_GRACE_SECONDS = 60;
|
|
26
|
+
const ENVELOPE_OPEN = '<cross-session-message from="';
|
|
27
|
+
const ENVELOPE_SENDER_CLOSE = '" from-name="hive">';
|
|
28
|
+
const EV_PROMPT = "(CASE WHEN json_valid(ev.payload) THEN COALESCE(json_extract(ev.payload, '$.prompt'), '') ELSE ev.payload END)";
|
|
29
|
+
const EV_MARKER = "(agent_messages.sender_tag || '[message #' || agent_messages.id || ',')";
|
|
30
|
+
const EV_HEADER_END = `instr(${EV_PROMPT}, char(10))`;
|
|
31
|
+
// The exact "[hive:worker NAME] [message #N," at prompt offset 0 (typed) or opening hive's own envelope body (socket).
|
|
32
|
+
const messageEvidenceSql = (select) => `
|
|
33
|
+
SELECT ${select} FROM agent_state_log ev
|
|
34
|
+
WHERE ev.actor_id = (SELECT a.actor_id FROM agents a WHERE a.id = agent_messages.to_agent_id)
|
|
35
|
+
AND ev.event = 'prompt'
|
|
36
|
+
AND ev.created_at >= COALESCE(agent_messages.socket_attempt_at, agent_messages.created_at)
|
|
37
|
+
AND (substr(${EV_PROMPT}, 1, length(${EV_MARKER})) = ${EV_MARKER}
|
|
38
|
+
OR (${EV_HEADER_END} > ${ENVELOPE_OPEN.length + ENVELOPE_SENDER_CLOSE.length}
|
|
39
|
+
AND substr(${EV_PROMPT}, 1, ${ENVELOPE_OPEN.length}) = '${ENVELOPE_OPEN}'
|
|
40
|
+
AND substr(${EV_PROMPT}, ${EV_HEADER_END} - ${ENVELOPE_SENDER_CLOSE.length}, ${ENVELOPE_SENDER_CLOSE.length}) = '${ENVELOPE_SENDER_CLOSE}'
|
|
41
|
+
AND instr(substr(${EV_PROMPT}, ${ENVELOPE_OPEN.length + 1}, ${EV_HEADER_END} - ${ENVELOPE_OPEN.length + ENVELOPE_SENDER_CLOSE.length + 1}), '"') = 0
|
|
42
|
+
AND substr(${EV_PROMPT}, ${EV_HEADER_END} + 1, length(${EV_MARKER})) = ${EV_MARKER}
|
|
43
|
+
AND instr(${EV_PROMPT}, char(10) || '${CROSS_SESSION_CLOSE}') > ${EV_HEADER_END}))`;
|
|
44
|
+
const RETAINED = "agent_messages.created_at >= datetime('now', '-7 days')";
|
|
45
|
+
function quietMessagingOn(projectId) {
|
|
46
|
+
const project = db.prepare("SELECT path FROM projects WHERE id = ?").get(projectId);
|
|
47
|
+
return project !== undefined && loadProjectYml(project.path).config?.quiet_messaging === true;
|
|
48
|
+
}
|
|
49
|
+
function eligible(options) {
|
|
50
|
+
const { projectId, fromActor, target, submit } = options;
|
|
51
|
+
if (!submit || target.kind !== "lead" || target.status !== "running" || target.actor_id === fromActor)
|
|
52
|
+
return false;
|
|
53
|
+
if (harnessFor(target.command).name !== "claude")
|
|
54
|
+
return false;
|
|
55
|
+
const sender = db
|
|
56
|
+
.prepare("SELECT 1 AS hit FROM agents WHERE project_id = ? AND actor_id = ? AND status = 'running' AND kind = 'agent'")
|
|
57
|
+
.get(projectId, fromActor);
|
|
58
|
+
return sender !== undefined && quietMessagingOn(projectId);
|
|
59
|
+
}
|
|
60
|
+
function registeredSocket(target) {
|
|
61
|
+
return (target.claude_messaging_socket !== "" &&
|
|
62
|
+
target.claude_messaging_pane_pid !== "" &&
|
|
63
|
+
target.claude_messaging_pane_pid === target.pane_pid);
|
|
64
|
+
}
|
|
65
|
+
function leadSession(agentId, panePid) {
|
|
66
|
+
const turn = readLeadTurnState(agentId);
|
|
67
|
+
return turn !== null && turn.pane_pid === panePid ? turn.session_id : "";
|
|
68
|
+
}
|
|
69
|
+
function readRow(id) {
|
|
70
|
+
return db.prepare("SELECT * FROM agent_messages WHERE id = ?").get(id);
|
|
71
|
+
}
|
|
72
|
+
function setStatus(id, from, to, fields = {}) {
|
|
73
|
+
const names = Object.keys(fields);
|
|
74
|
+
const sets = ["delivery_status = ?", ...names.map((n) => `${n} = ?`)].join(", ");
|
|
75
|
+
try {
|
|
76
|
+
return (db
|
|
77
|
+
.prepare(`UPDATE agent_messages SET ${sets} WHERE id = ? AND delivery_status = ?`)
|
|
78
|
+
.run(to, ...names.map((n) => fields[n]), id, from).changes === 1);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const NOW = "strftime('%Y-%m-%d %H:%M:%f', 'now')";
|
|
85
|
+
function stamp(id, column) {
|
|
86
|
+
try {
|
|
87
|
+
db.prepare(`UPDATE agent_messages SET ${column} = ${NOW} WHERE id = ?`).run(id);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function socketText(id, text, tag) {
|
|
93
|
+
return `${renderLeadMessage(id, text, tag)}\n\n${SOCKET_MESSAGE_FOOTER}`;
|
|
94
|
+
}
|
|
95
|
+
function acquirePaneLease(row, key) {
|
|
96
|
+
try {
|
|
97
|
+
db.prepare("INSERT OR IGNORE INTO actors (id, name, kind) VALUES (?, ?, 'agent')").run(row.from_actor, row.from_name);
|
|
98
|
+
db.prepare("DELETE FROM leases WHERE project_id = ? AND lock_key = ? AND expires_at < datetime('now')").run(row.project_id, key);
|
|
99
|
+
const held = db
|
|
100
|
+
.prepare(`INSERT INTO leases (project_id, lock_key, owner, expires_at)
|
|
101
|
+
VALUES (?, ?, ?, datetime('now', printf('+%d seconds', ?)))
|
|
102
|
+
ON CONFLICT(project_id, lock_key) DO NOTHING
|
|
103
|
+
RETURNING expires_at`)
|
|
104
|
+
.get(row.project_id, key, row.from_actor, PANE_LEASE_TTL_SECONDS);
|
|
105
|
+
return held?.expires_at ?? null;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function releasePaneLease(row, key, expiresAt) {
|
|
112
|
+
try {
|
|
113
|
+
db.prepare("DELETE FROM leases WHERE project_id = ? AND lock_key = ? AND expires_at = ?").run(row.project_id, key, expiresAt);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function hold(row, reason) {
|
|
119
|
+
try {
|
|
120
|
+
db.prepare("UPDATE agent_messages SET delivery_note = ? WHERE id = ? AND delivery_status = ?").run(reason, row.id, MESSAGE_STATUS.fallbackPending);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
}
|
|
124
|
+
return { kind: "held", reason };
|
|
125
|
+
}
|
|
126
|
+
function failPending(row, reason) {
|
|
127
|
+
setStatus(row.id, MESSAGE_STATUS.fallbackPending, MESSAGE_STATUS.failed, { delivery_note: reason });
|
|
128
|
+
return { kind: "failed", reason };
|
|
129
|
+
}
|
|
130
|
+
function pasteFailure(err, pasted, buffered) {
|
|
131
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
132
|
+
if (pasted) {
|
|
133
|
+
return err instanceof PaneOwnershipLost
|
|
134
|
+
? `pasted, then the Enter was withheld because pane ownership was lost: ${why} The text may be on screen unsubmitted.`
|
|
135
|
+
: `pasted, then the Enter failed: ${why} The text is on screen unsubmitted.`;
|
|
136
|
+
}
|
|
137
|
+
if (buffered && err instanceof TmuxTimeoutError)
|
|
138
|
+
return `the paste timed out, so the text MAY be on screen unsubmitted: ${why}`;
|
|
139
|
+
return `nothing was pasted: ${why}`;
|
|
140
|
+
}
|
|
141
|
+
// One guarded pane attempt. The claim is the CAS that makes it at most one per message.
|
|
142
|
+
async function paneFallback(row, snapshot) {
|
|
143
|
+
let identity;
|
|
144
|
+
try {
|
|
145
|
+
identity = paneIdentity(JSON.parse(row.target_identity ?? ""));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return failPending(row, "the accepted target identity is unreadable, so no pane can be certified as the lead's");
|
|
149
|
+
}
|
|
150
|
+
const target = db.prepare("SELECT kind, command FROM agents WHERE id = ?").get(row.to_agent_id);
|
|
151
|
+
const ownership = currentOwnership(row.to_agent_id, identity, snapshot);
|
|
152
|
+
if (ownership === "unknown")
|
|
153
|
+
return hold(row, "the lead's pane ownership reads unknown");
|
|
154
|
+
if (ownership !== "live" || target === undefined) {
|
|
155
|
+
return failPending(row, `the lead pane this message was accepted for is ${ownership}; nothing is typed into a replacement`);
|
|
156
|
+
}
|
|
157
|
+
const key = `wake-pane:${identity.tmux_socket}:${identity.tmux_target}`;
|
|
158
|
+
const expiresAt = acquirePaneLease(row, key);
|
|
159
|
+
if (expiresAt === null)
|
|
160
|
+
return hold(row, "another delivery holds the lead's pane");
|
|
161
|
+
let wrote = false;
|
|
162
|
+
try {
|
|
163
|
+
const pane = identity.tmux_target;
|
|
164
|
+
if (paneInCopyMode(pane) === true)
|
|
165
|
+
return hold(row, "the lead's pane is in copy mode");
|
|
166
|
+
const classifier = paneClassifierFor(target.command);
|
|
167
|
+
if (!classifier)
|
|
168
|
+
return hold(row, "hive cannot classify the lead's screen");
|
|
169
|
+
const choice = classifier.choiceCheck(pane).awaitingChoice;
|
|
170
|
+
if (choice !== false)
|
|
171
|
+
return hold(row, choice ? "the lead's pane is waiting on a choice" : "the lead's pane could not be read");
|
|
172
|
+
if (holdsHumanInput(classifier.inputBoxState(pane)))
|
|
173
|
+
return hold(row, "the lead's input box holds unsubmitted text");
|
|
174
|
+
const claimed = (() => {
|
|
175
|
+
try {
|
|
176
|
+
return (db
|
|
177
|
+
.prepare(`UPDATE agent_messages SET delivery_status = ?, fallback_claimed_at = ${NOW}
|
|
178
|
+
WHERE id = ? AND delivery_status = ? AND NOT EXISTS (${messageEvidenceSql("1")})`)
|
|
179
|
+
.run(MESSAGE_STATUS.fallbackClaimed, row.id, MESSAGE_STATUS.fallbackPending).changes === 1);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
})();
|
|
185
|
+
if (!claimed)
|
|
186
|
+
return { kind: "settled" };
|
|
187
|
+
let pasted = false;
|
|
188
|
+
let buffered = false;
|
|
189
|
+
wrote = true;
|
|
190
|
+
try {
|
|
191
|
+
await sendText(pane, renderLeadMessage(row.id, row.text, row.sender_tag ?? "", row.socket_attempt_at !== null), true, () => {
|
|
192
|
+
pasted = true;
|
|
193
|
+
stamp(row.id, "typed_at");
|
|
194
|
+
}, () => {
|
|
195
|
+
buffered = true;
|
|
196
|
+
}, () => requireStillOwned(row.to_agent_id, identity, `message #${row.id} fallback`));
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
const reason = pasteFailure(err, pasted, buffered);
|
|
200
|
+
setStatus(row.id, MESSAGE_STATUS.fallbackClaimed, MESSAGE_STATUS.failed, { delivery_note: reason });
|
|
201
|
+
return { kind: "failed", reason };
|
|
202
|
+
}
|
|
203
|
+
setStatus(row.id, MESSAGE_STATUS.fallbackClaimed, MESSAGE_STATUS.complete);
|
|
204
|
+
return { kind: "typed" };
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
if (wrote)
|
|
208
|
+
await sleep(PANE_LEASE_SETTLE_MS);
|
|
209
|
+
releasePaneLease(row, key, expiresAt);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function earlierFallbackPending(row) {
|
|
213
|
+
return (db
|
|
214
|
+
.prepare(`SELECT 1 AS hit FROM agent_messages
|
|
215
|
+
WHERE from_actor = ? AND to_agent_id = ? AND id < ? AND delivery_status = ? AND ${RETAINED}`)
|
|
216
|
+
.get(row.from_actor, row.to_agent_id, row.id, MESSAGE_STATUS.fallbackPending) !== undefined);
|
|
217
|
+
}
|
|
218
|
+
const DO_NOT_RESEND = "do not resend";
|
|
219
|
+
// null means this send is not eligible, and agent_send's ordinary pane path runs unchanged.
|
|
220
|
+
export async function sendQuietLeadMessage(options) {
|
|
221
|
+
if (!eligible(options))
|
|
222
|
+
return null;
|
|
223
|
+
const { projectId, fromActor, target, text } = options;
|
|
224
|
+
const identity = paneIdentity(target);
|
|
225
|
+
const tag = senderTag(projectId, fromActor);
|
|
226
|
+
const session = leadSession(target.id, target.pane_pid);
|
|
227
|
+
const { id } = storeLeadMessage(projectId, fromActor, target.id, text);
|
|
228
|
+
const long = text.length > LEAD_MESSAGE_THRESHOLD;
|
|
229
|
+
const base = { agent_id: target.id, name: target.name, message_id: id, ...(long ? { shortened: true } : {}) };
|
|
230
|
+
const shortNote = (channel, redelivered = false) => long ? ` ${shortenedSendNote(id, renderLeadMessage(id, text, tag, redelivered).length, channel)}` : "";
|
|
231
|
+
const body = socketText(id, text, tag);
|
|
232
|
+
const skip = !registeredSocket(target)
|
|
233
|
+
? "no messaging socket registered for this lead pane"
|
|
234
|
+
: body.includes(CROSS_SESSION_CLOSE)
|
|
235
|
+
? "the text contains the envelope's closing tag"
|
|
236
|
+
: null;
|
|
237
|
+
// One statement makes the row visible to the scheduler, already in its route's status.
|
|
238
|
+
db.prepare(`UPDATE agent_messages SET delivery_status = ?, delivery_method = ?, socket_attempt_at = ${skip === null ? NOW : "NULL"},
|
|
239
|
+
delivery_note = ?, target_identity = ?, sender_tag = ? WHERE id = ?`).run(skip === null ? MESSAGE_STATUS.socketPending : MESSAGE_STATUS.fallbackPending, skip === null ? MESSAGE_METHOD.socket : MESSAGE_METHOD.pty, skip, JSON.stringify(identity), tag, id);
|
|
240
|
+
if (skip === null) {
|
|
241
|
+
const posted = await postClaudeWake({
|
|
242
|
+
socketPath: target.claude_messaging_socket,
|
|
243
|
+
senderAddress: senderAddress(join(dataDir, "wake-sender.sock")),
|
|
244
|
+
text: body,
|
|
245
|
+
beforeWrite: () => {
|
|
246
|
+
requireStillOwned(target.id, identity, `message #${id} socket delivery`);
|
|
247
|
+
const now = db
|
|
248
|
+
.prepare(`SELECT a.status, a.kind, a.pane_pid, a.claude_messaging_socket AS socket, a.claude_messaging_pane_pid AS pid,
|
|
249
|
+
m.delivery_status
|
|
250
|
+
FROM agents a, agent_messages m WHERE a.id = ? AND m.id = ?`)
|
|
251
|
+
.get(target.id, id);
|
|
252
|
+
return (now !== undefined &&
|
|
253
|
+
now.status === "running" &&
|
|
254
|
+
now.kind === "lead" &&
|
|
255
|
+
now.socket === target.claude_messaging_socket &&
|
|
256
|
+
now.pid === identity.pane_pid &&
|
|
257
|
+
now.pane_pid === identity.pane_pid &&
|
|
258
|
+
now.delivery_status === MESSAGE_STATUS.socketPending &&
|
|
259
|
+
leadSession(target.id, identity.pane_pid) === session);
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
if (posted) {
|
|
263
|
+
return {
|
|
264
|
+
...base,
|
|
265
|
+
sent: true,
|
|
266
|
+
pending: true,
|
|
267
|
+
delivery_method: MESSAGE_METHOD.socket,
|
|
268
|
+
confirmed: false,
|
|
269
|
+
note: `Posted locally to the lead's messaging socket as message #${id}, awaiting prompt confirmation; ${DO_NOT_RESEND}. ` +
|
|
270
|
+
"sent means handed to the transport, not read: if no prompt confirms it, hive types it into the lead's pane once." +
|
|
271
|
+
shortNote("socket"),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
setStatus(id, MESSAGE_STATUS.socketPending, MESSAGE_STATUS.fallbackPending, {
|
|
275
|
+
delivery_method: MESSAGE_METHOD.ptyAfterSocketTimeout,
|
|
276
|
+
delivery_note: "the socket post failed or timed out",
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const row = readRow(id);
|
|
280
|
+
const method = (row.delivery_method ?? MESSAGE_METHOD.pty);
|
|
281
|
+
const outcome = earlierFallbackPending(row)
|
|
282
|
+
? hold(row, "an earlier message from this sender to this lead is still awaiting its pane fallback, and this one follows it")
|
|
283
|
+
: await paneFallback(row);
|
|
284
|
+
if (outcome.kind === "typed") {
|
|
285
|
+
const note = shortNote("pane", row.socket_attempt_at !== null).trim();
|
|
286
|
+
return { ...base, sent: true, pending: false, delivery_method: method, ...(note ? { note } : {}) };
|
|
287
|
+
}
|
|
288
|
+
if (outcome.kind === "held") {
|
|
289
|
+
return {
|
|
290
|
+
...base,
|
|
291
|
+
sent: false,
|
|
292
|
+
pending: true,
|
|
293
|
+
delivery_method: method,
|
|
294
|
+
note: `Not typed yet: ${outcome.reason}. Message #${id} is accepted and stored, and hive retries it from the ` +
|
|
295
|
+
`scheduler; do NOT resubmit it. agent_message_get(${id}) shows its delivery state.` +
|
|
296
|
+
shortNote("socket"),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
if (outcome.kind === "settled") {
|
|
300
|
+
const now = readRow(id);
|
|
301
|
+
return {
|
|
302
|
+
...base,
|
|
303
|
+
sent: true,
|
|
304
|
+
pending: now?.delivery_status !== MESSAGE_STATUS.complete,
|
|
305
|
+
delivery_method: method,
|
|
306
|
+
confirmed: now?.confirmed_at != null,
|
|
307
|
+
note: `Message #${id} was settled by another delivery path; ${DO_NOT_RESEND}. agent_message_get(${id}) shows its state.`,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
throw new Error(`[agent_send:quiet-fallback-failed] message #${id} to ${target.name}: ${outcome.reason} hive will not type it again. ` +
|
|
311
|
+
`Do NOT resend blindly: read the lead's pane first, and agent_message_get(${id}) shows the recorded state.`);
|
|
312
|
+
}
|
|
313
|
+
function graceExpired(row, actorId, panePid) {
|
|
314
|
+
const firstStop = db
|
|
315
|
+
.prepare("SELECT MIN(created_at) AS t FROM agent_state_log WHERE actor_id = ? AND event = 'stop' AND created_at >= ?")
|
|
316
|
+
.get(actorId, row.socket_attempt_at).t;
|
|
317
|
+
let from = firstStop;
|
|
318
|
+
if (from === null) {
|
|
319
|
+
const turn = readLeadTurnState(row.to_agent_id);
|
|
320
|
+
if (turn !== null && turn.state === "working" && turn.pane_pid === panePid)
|
|
321
|
+
return false;
|
|
322
|
+
from = row.socket_attempt_at;
|
|
323
|
+
}
|
|
324
|
+
return db.prepare("SELECT (julianday('now') - julianday(?)) * 86400 >= ? AS due").get(from, CONFIRM_GRACE_SECONDS).due === 1;
|
|
325
|
+
}
|
|
326
|
+
function confirmFromPrompts() {
|
|
327
|
+
db.prepare(`UPDATE agent_messages SET confirmed_at = (${messageEvidenceSql("MIN(ev.created_at)")}), delivery_status = ?
|
|
328
|
+
WHERE delivery_status IS NOT NULL AND delivery_status != ? AND confirmed_at IS NULL AND ${RETAINED}
|
|
329
|
+
AND EXISTS (${messageEvidenceSql("1")})`).run(MESSAGE_STATUS.complete, MESSAGE_STATUS.complete);
|
|
330
|
+
}
|
|
331
|
+
function expireGrace() {
|
|
332
|
+
const pending = db
|
|
333
|
+
.prepare(`SELECT agent_messages.* FROM agent_messages WHERE delivery_status = ? AND ${RETAINED} ORDER BY id`)
|
|
334
|
+
.all(MESSAGE_STATUS.socketPending);
|
|
335
|
+
for (const row of pending) {
|
|
336
|
+
try {
|
|
337
|
+
const target = db.prepare("SELECT actor_id FROM agents WHERE id = ?").get(row.to_agent_id);
|
|
338
|
+
const panePid = JSON.parse(row.target_identity ?? "{}").pane_pid ?? "";
|
|
339
|
+
if (target === undefined || !graceExpired(row, target.actor_id, panePid))
|
|
340
|
+
continue;
|
|
341
|
+
db.prepare(`UPDATE agent_messages SET delivery_status = ?, delivery_method = ?, delivery_note = ?
|
|
342
|
+
WHERE id = ? AND delivery_status = ? AND NOT EXISTS (${messageEvidenceSql("1")})`).run(MESSAGE_STATUS.fallbackPending, MESSAGE_METHOD.ptyAfterSocketTimeout, "no prompt confirmed the socket post within its grace", row.id, MESSAGE_STATUS.socketPending);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// Runs inside tick's try; each row's failure is contained so one bad delivery cannot strand the rest.
|
|
349
|
+
export async function retryQuietLeadMessages(snapshot) {
|
|
350
|
+
try {
|
|
351
|
+
confirmFromPrompts();
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
}
|
|
355
|
+
expireGrace();
|
|
356
|
+
let due;
|
|
357
|
+
try {
|
|
358
|
+
due = db
|
|
359
|
+
.prepare(`SELECT agent_messages.* FROM agent_messages WHERE delivery_status = ? AND ${RETAINED} ORDER BY id`)
|
|
360
|
+
.all(MESSAGE_STATUS.fallbackPending);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const blocked = new Set();
|
|
366
|
+
for (const row of due) {
|
|
367
|
+
const pair = `${row.from_actor}\u0000${row.to_agent_id}`;
|
|
368
|
+
if (blocked.has(pair))
|
|
369
|
+
continue;
|
|
370
|
+
try {
|
|
371
|
+
const outcome = await paneFallback(row, snapshot);
|
|
372
|
+
if (outcome.kind === "held")
|
|
373
|
+
blocked.add(pair);
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
blocked.add(pair);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
package/dist/scheduler.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { QUEEN_AUDIT_RETENTION, QUEEN_AUDIT_MAX_ROWS, recentQueenAudit } from "./queenAudit.js";
|
|
2
|
-
import { runningBuildChange, runningBuildNotice } from "./version.js";
|
|
2
|
+
import { runningBuildChange, runningBuildNotice, versionInfo } from "./version.js";
|
|
3
3
|
import { existsSync, lstatSync, mkdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { dirname, join, sep } from "node:path";
|
|
@@ -19,12 +19,15 @@ import { describeLiveTasks, describeOneTask, liveBackgroundTasks, STALL_BOUND_SE
|
|
|
19
19
|
import { MESSAGE_MAX_ROWS, MESSAGE_RETENTION } from "./leadMessage.js";
|
|
20
20
|
import { CROSS_SESSION_CLOSE, postClaudeWake, senderAddress, SOCKET_WAKE_FOOTER } from "./claudeWake.js";
|
|
21
21
|
import { readLeadTurnState } from "./leadState.js";
|
|
22
|
+
import { retryQuietLeadMessages } from "./leadMessageDelivery.js";
|
|
22
23
|
import { harnessFor, hasTranscriptSignal, paneClassifierFor, screenClassifiable, transcriptDirFor } from "./harnesses.js";
|
|
23
24
|
import { transcriptDir, readContextFill } from "./transcript.js";
|
|
24
25
|
import { ageSecondsSince, describeLastLogEvent, humanizeAge, lastLogEvent, reportsAgentStateLog, } from "./stateProvenance.js";
|
|
25
26
|
import { capturePane, foreignSocket, holdsHumanInput, liveTargets, maskChoiceMarker, observationFailed, paneInCopyMode, paneReissued, rowAlive, rowAliveProbe, rowLiveProbe, rowOwnership, renderTrailerView, sanitizeTail, sendText, tailCaptureLines, sleep, tmuxSocketPath, } from "./tmux.js";
|
|
26
27
|
import { appendTeardown, NOT_ATTRIBUTED, sqlNow, teardownWindow, TEARDOWN_TRIGGER, } from "./teardown.js";
|
|
27
28
|
export const WATCH_SCOPE_PROJECT = "project";
|
|
29
|
+
const DELIVERED_BY = `pid ${process.pid}, ${versionInfo().line}`;
|
|
30
|
+
const deliveredByForFiring = (timer) => timer.repeat_every_ms == null ? DELIVERED_BY : `${DELIVERED_BY}, firing #${timer.fire_count}`;
|
|
28
31
|
const isStandingWatch = (timer) => timer.watch_scope === WATCH_SCOPE_PROJECT;
|
|
29
32
|
export const DELIVER_SOCKET_JOIN = `LEFT JOIN agents ON agents.id = (
|
|
30
33
|
SELECT a.id FROM agents a WHERE a.actor_id = wakes.deliver_actor
|
|
@@ -466,8 +469,13 @@ export function checkConfirmations() {
|
|
|
466
469
|
WHERE ${sentAt} IS NOT NULL AND confirmed_at IS NULL AND ${sentAt} >= datetime('now', ?) LIMIT 1`).get(LOG_RETENTION);
|
|
467
470
|
if (!pending)
|
|
468
471
|
return;
|
|
472
|
+
stmt(`UPDATE wakes SET confirmed_at = (${repeatingEvidenceSql("MIN(ev.created_at)")})
|
|
473
|
+
WHERE ${REPEATING_SOCKET_FIRING} AND socket_attempt_at IS NOT NULL AND confirmed_at IS NULL
|
|
474
|
+
AND socket_attempt_at >= datetime('now', ?)
|
|
475
|
+
AND EXISTS (${repeatingEvidenceSql("1")})`).run(LOG_RETENTION);
|
|
469
476
|
stmt(`UPDATE wakes SET confirmed_at = (${confirmationQuery})
|
|
470
477
|
WHERE ${sentAt} IS NOT NULL AND confirmed_at IS NULL AND ${sentAt} >= datetime('now', ?)
|
|
478
|
+
AND NOT (${REPEATING_SOCKET_FIRING})
|
|
471
479
|
AND EXISTS (${confirmationQuery.replace("MIN(created_at)", "1")})`).run(LOG_RETENTION);
|
|
472
480
|
}
|
|
473
481
|
catch {
|
|
@@ -513,6 +521,7 @@ export async function tick(snapshot) {
|
|
|
513
521
|
await maybeFireIdle(timer, snapshot, now, choices);
|
|
514
522
|
}
|
|
515
523
|
await retryUnconfirmedSocketWakes(snapshot, choices);
|
|
524
|
+
await retryQuietLeadMessages(snapshot);
|
|
516
525
|
}
|
|
517
526
|
catch {
|
|
518
527
|
}
|
|
@@ -569,11 +578,12 @@ function forgetPaneAnswers(pane, choices) {
|
|
|
569
578
|
noDialogUntil.delete(pane);
|
|
570
579
|
}
|
|
571
580
|
function holdTimer(timer, reason) {
|
|
581
|
+
const count = timer.fire_count ?? null;
|
|
572
582
|
bestEffortRun(`UPDATE wakes SET held_at = datetime('now'), held_reason = ?,
|
|
573
583
|
first_held_at = COALESCE(CASE WHEN held_at IS NULL THEN NULL ELSE first_held_at END, datetime('now'))
|
|
574
584
|
WHERE id = ? AND cancelled_at IS NULL
|
|
575
|
-
AND (fired_at IS NULL OR (repeat_every_ms IS NOT NULL AND due_at = ?)
|
|
576
|
-
OR (${socketAwaitingWhere()}))`, reason, timer.id, timer.due_at);
|
|
585
|
+
AND (fired_at IS NULL OR (repeat_every_ms IS NOT NULL AND due_at = ? AND (? IS NULL OR fire_count = ?))
|
|
586
|
+
OR (${socketAwaitingWhere()}))`, reason, timer.id, timer.due_at, count, count);
|
|
577
587
|
}
|
|
578
588
|
const HELD_REASON_MODAL_CHOICE = "pane is awaiting a modal choice (folder-trust or /model picker)";
|
|
579
589
|
export const HELD_REASON_LEAD_PANE_DEAD = "the lead's pane is not live right now (likely mid-restart); lead-owned wakes are exempt from " +
|
|
@@ -658,6 +668,8 @@ function conversationHoldsWake(timer, snapshot) {
|
|
|
658
668
|
}
|
|
659
669
|
return (stmt(`SELECT 1 AS hit FROM agent_state_log
|
|
660
670
|
WHERE actor_id = ? AND event = 'prompt' AND CASE WHEN json_valid(payload) THEN COALESCE(json_extract(payload, '$.prompt'), payload) ELSE payload END NOT LIKE '%[hive wake #%' AND CASE WHEN json_valid(payload) THEN COALESCE(json_extract(payload, '$.prompt'), payload) ELSE payload END NOT LIKE '[hive:%'
|
|
671
|
+
AND CASE WHEN json_valid(payload) THEN COALESCE(json_extract(payload, '$.prompt'), payload) ELSE payload END NOT LIKE '<task-notification>%'
|
|
672
|
+
AND CASE WHEN json_valid(payload) THEN COALESCE(json_extract(payload, '$.prompt'), payload) ELSE payload END NOT LIKE '<cross-session-message from="%" from-name="hive">' || char(10) || '[hive:worker %] [message #%,%' || char(10) || '</cross-session-message>%'
|
|
661
673
|
AND created_at >= datetime('now', ?)
|
|
662
674
|
ORDER BY id DESC LIMIT 1`).get(timer.deliver_actor, CONVERSATION_HOLD_TTL) !== undefined);
|
|
663
675
|
}
|
|
@@ -1610,28 +1622,33 @@ async function fireDelay(timer, snapshot, choices) {
|
|
|
1610
1622
|
bestEffortRun("UPDATE wakes SET cancelled_at = datetime('now') WHERE id = ? AND created_at IS ?", timer.id, timer.created_at);
|
|
1611
1623
|
return;
|
|
1612
1624
|
}
|
|
1625
|
+
if (timer.repeat_every_ms != null && repeatingFiringPending(timer.id))
|
|
1626
|
+
return;
|
|
1613
1627
|
await withPaneClaim(timer, () => fireDelayClaimed(timer, snapshot, choices));
|
|
1614
1628
|
}
|
|
1615
1629
|
async function fireDelayClaimed(timer, snapshot, choices) {
|
|
1616
1630
|
const decision = deliverable(timer, snapshot, choices);
|
|
1617
1631
|
if (!decision.ok)
|
|
1618
1632
|
return false;
|
|
1619
|
-
let
|
|
1633
|
+
let firing = timer;
|
|
1620
1634
|
if (timer.repeat_every_ms != null) {
|
|
1621
1635
|
const seconds = Math.max(1, Math.round(timer.repeat_every_ms / 1000));
|
|
1622
|
-
claimed =
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
claimed
|
|
1636
|
+
const claimed = stmt(`UPDATE wakes SET due_at = datetime('now', printf('+%d seconds', ?)),
|
|
1637
|
+
fired_at = datetime('now'), fire_count = fire_count + 1,
|
|
1638
|
+
typed_at = NULL, confirmed_at = NULL, held_at = NULL, held_reason = NULL, typed_busy = NULL,
|
|
1639
|
+
typed_seen = NULL, first_held_at = NULL,
|
|
1640
|
+
socket_attempt_at = NULL, socket_delivery_note = NULL, delivery_method = NULL, delivered_by = NULL
|
|
1641
|
+
WHERE id = ? AND due_at IS ? AND body IS ? AND repeat_every_ms IS ? AND cancelled_at IS NULL
|
|
1642
|
+
AND fire_count = ? AND NOT COALESCE((${REPEATING_FIRING_PENDING}), 0)
|
|
1643
|
+
RETURNING fire_count, fired_at`).get(seconds, timer.id, timer.due_at, timer.body, timer.repeat_every_ms, timer.fire_count);
|
|
1644
|
+
if (claimed === undefined)
|
|
1645
|
+
return false;
|
|
1646
|
+
firing = { ...timer, fire_count: claimed.fire_count, fired_at: claimed.fired_at, socket_attempt_at: null, socket_delivery_note: null, delivery_method: null };
|
|
1631
1647
|
}
|
|
1632
|
-
if (!
|
|
1648
|
+
else if (!claimOneShot(timer)) {
|
|
1633
1649
|
return false;
|
|
1634
|
-
|
|
1650
|
+
}
|
|
1651
|
+
await deliver(firing, "", choices, decision.typedSeen, decision.firstHeldAt, decision.owner);
|
|
1635
1652
|
return true;
|
|
1636
1653
|
}
|
|
1637
1654
|
function claimOneShot(timer) {
|
|
@@ -1889,14 +1906,39 @@ export function shortRenderForLeadDelivery(timer) {
|
|
|
1889
1906
|
return standingNoticeBodyShort(timer, candidates, timer.id);
|
|
1890
1907
|
}
|
|
1891
1908
|
// The claim already happened, so this records why and never re-arms or replays the wake.
|
|
1892
|
-
function recordLostAfterClaim(timer, pasted, why) {
|
|
1909
|
+
function recordLostAfterClaim(timer, pasted, why, firing = null) {
|
|
1893
1910
|
bestEffortRun(`UPDATE wakes SET held_reason = ?,
|
|
1894
1911
|
typed_at = CASE WHEN ? = 1 THEN strftime('%Y-%m-%d %H:%M:%f', 'now') ELSE typed_at END,
|
|
1895
1912
|
cancelled_at = CASE WHEN repeat_every_ms IS NULL THEN datetime('now') ELSE cancelled_at END
|
|
1896
|
-
WHERE id =
|
|
1913
|
+
WHERE id = ? AND (? IS NULL OR fire_count = ?)`, HELD_REASON_OWNERSHIP_LOST_AFTER_CLAIM + why + (pasted ? " The body was pasted; no Enter was sent." : ""), pasted ? 1 : 0, timer.id, firing, firing);
|
|
1897
1914
|
}
|
|
1898
|
-
export const DELIVERY_METHOD = {
|
|
1915
|
+
export const DELIVERY_METHOD = {
|
|
1916
|
+
socket: "socket",
|
|
1917
|
+
socketRepeating: "socket-repeating",
|
|
1918
|
+
pty: "pty",
|
|
1919
|
+
ptyAfterSocketTimeout: "pty-after-socket-timeout",
|
|
1920
|
+
};
|
|
1899
1921
|
export const socketAwaitingWhere = (prefix = "") => `${prefix}delivery_method = '${DELIVERY_METHOD.socket}' AND ${prefix}confirmed_at IS NULL AND ${prefix}typed_at IS NULL AND ${prefix}cancelled_at IS NULL`;
|
|
1922
|
+
// An attempt older than the row's latest claim belongs to an earlier firing (an old-build claim leaves it behind).
|
|
1923
|
+
const currentAttempt = (p) => `${p}socket_attempt_at IS NOT NULL AND ${p}confirmed_at IS NULL AND ${p}typed_at IS NULL AND ${p}cancelled_at IS NULL
|
|
1924
|
+
AND ${p}fired_at IS NOT NULL AND ${p}socket_attempt_at >= ${p}fired_at`;
|
|
1925
|
+
const repeatingSocketAwaitingWhere = (prefix = "") => `${prefix}delivery_method = '${DELIVERY_METHOD.socketRepeating}' AND ${currentAttempt(prefix)}`;
|
|
1926
|
+
const FIRING_DROPPED_NOTE = "dropped";
|
|
1927
|
+
const repeatingFallbackClaimedWhere = (prefix = "") => `${prefix}repeat_every_ms IS NOT NULL AND ${prefix}delivery_method = '${DELIVERY_METHOD.ptyAfterSocketTimeout}' AND ${currentAttempt(prefix)}
|
|
1928
|
+
AND ${prefix}socket_delivery_note IS NOT '${FIRING_DROPPED_NOTE}'`;
|
|
1929
|
+
// Bounded like retry and confirmation: past retention neither can settle it, so it must stop deferring.
|
|
1930
|
+
const REPEATING_FIRING_PENDING = `((${repeatingSocketAwaitingWhere()}) OR (${repeatingFallbackClaimedWhere()}))
|
|
1931
|
+
AND socket_attempt_at >= datetime('now', '${LOG_RETENTION}')`;
|
|
1932
|
+
const REPEATING_SOCKET_FIRING = `repeat_every_ms IS NOT NULL AND COALESCE(delivery_method, '') IN ('${DELIVERY_METHOD.socketRepeating}', '${DELIVERY_METHOD.ptyAfterSocketTimeout}')`;
|
|
1933
|
+
function repeatingFiringPending(id) {
|
|
1934
|
+
try {
|
|
1935
|
+
return stmt(`SELECT 1 AS hit FROM wakes WHERE id = ? AND (${REPEATING_FIRING_PENDING})`).get(id) !== undefined;
|
|
1936
|
+
}
|
|
1937
|
+
catch {
|
|
1938
|
+
return true;
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
const firingMarker = (timer) => `[hive wake #${timer.id} firing #${timer.fire_count}`;
|
|
1900
1942
|
const SOCKET_CONFIRM_GRACE_SECONDS = 60;
|
|
1901
1943
|
const withReDelivered = (note) => (note ? `${note}, re-delivered` : "re-delivered");
|
|
1902
1944
|
const promptEvidenceSql = (lowerBound) => `
|
|
@@ -1904,14 +1946,30 @@ const promptEvidenceSql = (lowerBound) => `
|
|
|
1904
1946
|
WHERE actor_id = wakes.deliver_actor AND event = 'prompt' AND created_at >= ${lowerBound}
|
|
1905
1947
|
AND (payload LIKE '%[hive wake #' || wakes.id || ']%'
|
|
1906
1948
|
OR payload LIKE '%[hive wake #' || wakes.id || ',%')`;
|
|
1949
|
+
const ENVELOPE_OPEN = '<cross-session-message from="';
|
|
1950
|
+
const ENVELOPE_SENDER_CLOSE = '" from-name="hive">';
|
|
1951
|
+
const EV_PROMPT = "(CASE WHEN json_valid(ev.payload) THEN COALESCE(json_extract(ev.payload, '$.prompt'), '') ELSE ev.payload END)";
|
|
1952
|
+
const EV_MARKER = "('[hive wake #' || wakes.id || ' firing #' || wakes.fire_count)";
|
|
1953
|
+
const markerAt = (start) => `substr(${EV_PROMPT}, ${start}, length(${EV_MARKER}) + 1) IN (${EV_MARKER} || ']', ${EV_MARKER} || ',')`;
|
|
1954
|
+
const EV_HEADER_END = `instr(${EV_PROMPT}, char(10))`;
|
|
1955
|
+
// The current firing's exact marker, at prompt offset 0 (typed) or opening hive's envelope body (socket).
|
|
1956
|
+
const repeatingEvidenceSql = (select) => `
|
|
1957
|
+
SELECT ${select} FROM agent_state_log ev
|
|
1958
|
+
WHERE ev.actor_id = wakes.deliver_actor AND ev.event = 'prompt' AND ev.created_at >= wakes.socket_attempt_at
|
|
1959
|
+
AND (${markerAt("1")}
|
|
1960
|
+
OR (${EV_HEADER_END} > ${ENVELOPE_OPEN.length + ENVELOPE_SENDER_CLOSE.length}
|
|
1961
|
+
AND substr(${EV_PROMPT}, 1, ${ENVELOPE_OPEN.length}) = '${ENVELOPE_OPEN}'
|
|
1962
|
+
AND substr(${EV_PROMPT}, ${EV_HEADER_END} - ${ENVELOPE_SENDER_CLOSE.length}, ${ENVELOPE_SENDER_CLOSE.length}) = '${ENVELOPE_SENDER_CLOSE}'
|
|
1963
|
+
AND instr(substr(${EV_PROMPT}, ${ENVELOPE_OPEN.length + 1}, ${EV_HEADER_END} - ${ENVELOPE_OPEN.length + ENVELOPE_SENDER_CLOSE.length + 1}), '"') = 0
|
|
1964
|
+
AND ${markerAt(`${EV_HEADER_END} + 1`)}
|
|
1965
|
+
AND instr(${EV_PROMPT}, char(10) || '${CROSS_SESSION_CLOSE}') > ${EV_HEADER_END}))`;
|
|
1907
1966
|
function quietMessagingOn(projectId) {
|
|
1908
1967
|
const project = stmt("SELECT path FROM projects WHERE id = ?").get(projectId);
|
|
1909
1968
|
return project !== undefined && loadProjectYml(project.path).config?.quiet_messaging === true;
|
|
1910
1969
|
}
|
|
1911
|
-
// Only a
|
|
1970
|
+
// Only a wake to a live, row-owned Claude lead whose socket was registered by this pane epoch.
|
|
1912
1971
|
function socketRoute(timer, owner) {
|
|
1913
1972
|
return (owner !== null &&
|
|
1914
|
-
timer.repeat_every_ms == null &&
|
|
1915
1973
|
isLeadActorId(timer.deliver_actor) &&
|
|
1916
1974
|
harnessFor(timer.deliver_command).name === "claude" &&
|
|
1917
1975
|
timer.deliver_messaging_socket !== "" &&
|
|
@@ -1924,23 +1982,72 @@ function socketStillRegistered(timer, owner) {
|
|
|
1924
1982
|
requireStillOwned(owner.rowId, owner.identity, `wake #${timer.id} socket delivery`);
|
|
1925
1983
|
const row = stmt(`SELECT claude_messaging_socket AS socket, claude_messaging_pane_pid AS pid, pane_pid FROM agents
|
|
1926
1984
|
WHERE id = ? AND status = 'running' AND kind = 'lead'`).get(owner.rowId);
|
|
1927
|
-
const wake =
|
|
1985
|
+
const wake = timer.repeat_every_ms == null
|
|
1986
|
+
? stmt("SELECT 1 AS live FROM wakes WHERE id = ? AND cancelled_at IS NULL").get(timer.id)
|
|
1987
|
+
: stmt("SELECT 1 AS live FROM wakes WHERE id = ? AND cancelled_at IS NULL AND fire_count = ?").get(timer.id, timer.fire_count);
|
|
1928
1988
|
return (wake !== undefined &&
|
|
1929
1989
|
row !== undefined &&
|
|
1930
1990
|
row.socket === timer.deliver_messaging_socket &&
|
|
1931
1991
|
row.pid === timer.deliver_messaging_pane_pid &&
|
|
1932
1992
|
row.pid === row.pane_pid);
|
|
1933
1993
|
}
|
|
1994
|
+
// A failed stamp means the firing was cancelled or superseded, so nothing is posted.
|
|
1995
|
+
function stampRepeatingAttempt(timer, note, firstHeldAt) {
|
|
1996
|
+
try {
|
|
1997
|
+
return (stmt(`UPDATE wakes SET socket_attempt_at = strftime('%Y-%m-%d %H:%M:%f', 'now'), socket_delivery_note = ?,
|
|
1998
|
+
delivery_method = ?, delivered_by = ?, first_held_at = ?, held_at = NULL, held_reason = NULL, confirmed_at = NULL
|
|
1999
|
+
WHERE id = ? AND fire_count = ? AND cancelled_at IS NULL AND typed_at IS NULL AND socket_attempt_at IS NULL`).run(note, DELIVERY_METHOD.socketRepeating, deliveredByForFiring(timer), firstHeldAt, timer.id, timer.fire_count).changes === 1);
|
|
2000
|
+
}
|
|
2001
|
+
catch {
|
|
2002
|
+
return false;
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
// At most one fallback per (id, firing): the method leaves socket-repeating here and never returns to it.
|
|
2006
|
+
function claimRepeatingFallback(timer) {
|
|
2007
|
+
try {
|
|
2008
|
+
return (stmt(`UPDATE wakes SET delivery_method = ?, delivered_by = ?
|
|
2009
|
+
WHERE id = ? AND fire_count = ? AND ${repeatingSocketAwaitingWhere()}
|
|
2010
|
+
AND NOT EXISTS (${repeatingEvidenceSql("1")})`).run(DELIVERY_METHOD.ptyAfterSocketTimeout, deliveredByForFiring(timer), timer.id, timer.fire_count).changes === 1);
|
|
2011
|
+
}
|
|
2012
|
+
catch {
|
|
2013
|
+
return false;
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
class FiringSuperseded extends Error {
|
|
2017
|
+
}
|
|
2018
|
+
// Nothing reached the pane, so this firing is settled as dropped: the next one delivers, nothing replays it.
|
|
2019
|
+
// The marker lives in socket_delivery_note because hive lead's re-point clears held_reason.
|
|
2020
|
+
function dropRepeatingFiring(timer, firing, err) {
|
|
2021
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
2022
|
+
bestEffortRun(`UPDATE wakes SET socket_delivery_note = ?, held_reason = ?
|
|
2023
|
+
WHERE id = ? AND fire_count = ? AND delivery_method = ? AND typed_at IS NULL`, FIRING_DROPPED_NOTE, `${HELD_REASON_OWNERSHIP_LOST_AFTER_CLAIM}${why} Firing #${firing} was not delivered; the next firing goes out on schedule.`, timer.id, firing, DELIVERY_METHOD.ptyAfterSocketTimeout);
|
|
2024
|
+
}
|
|
2025
|
+
function requireCurrentFiring(id, firing) {
|
|
2026
|
+
const row = stmt("SELECT 1 AS hit FROM wakes WHERE id = ? AND fire_count = ? AND cancelled_at IS NULL").get(id, firing);
|
|
2027
|
+
if (row === undefined)
|
|
2028
|
+
throw new FiringSuperseded(`wake #${id} firing #${firing} was superseded`);
|
|
2029
|
+
}
|
|
1934
2030
|
async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner, fallback = false) {
|
|
1935
2031
|
const tail = watchedTail(timer);
|
|
1936
2032
|
const body = (isLeadActorId(timer.deliver_actor) ? shortRenderForLeadDelivery(timer) : null) ?? timer.body;
|
|
1937
2033
|
const staleness = noticeStalenessNote(timer);
|
|
2034
|
+
// A repeating wake that took the socket keeps its firing number in every marker and every write.
|
|
2035
|
+
let firing = fallback && timer.delivery_method === DELIVERY_METHOD.socketRepeating ? timer.fire_count : null;
|
|
1938
2036
|
if (!fallback && socketRoute(timer, owner)) {
|
|
1939
|
-
const
|
|
2037
|
+
const repeating = timer.repeat_every_ms != null;
|
|
2038
|
+
const head = repeating ? firingMarker(timer) : `[hive wake #${timer.id}`;
|
|
2039
|
+
const text = `${head}${note ? `, ${note}` : ""}] ${body + staleness + tail}\n\n${SOCKET_WAKE_FOOTER}`;
|
|
1940
2040
|
if (!text.includes(CROSS_SESSION_CLOSE)) {
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
2041
|
+
if (repeating) {
|
|
2042
|
+
if (!stampRepeatingAttempt(timer, note, firstHeldAt))
|
|
2043
|
+
return;
|
|
2044
|
+
firing = timer.fire_count;
|
|
2045
|
+
}
|
|
2046
|
+
else {
|
|
2047
|
+
bestEffortRun(`UPDATE wakes SET socket_attempt_at = strftime('%Y-%m-%d %H:%M:%f', 'now'), socket_delivery_note = ?,
|
|
2048
|
+
delivery_method = ?, delivered_by = ?, first_held_at = ?, held_at = NULL, held_reason = NULL, confirmed_at = NULL
|
|
2049
|
+
WHERE id = ?`, note, DELIVERY_METHOD.socket, deliveredByForFiring(timer), firstHeldAt, timer.id);
|
|
2050
|
+
}
|
|
1944
2051
|
const posted = await postClaudeWake({
|
|
1945
2052
|
socketPath: timer.deliver_messaging_socket,
|
|
1946
2053
|
senderAddress: senderAddress(join(dataDir, "wake-sender.sock")),
|
|
@@ -1951,12 +2058,14 @@ async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner, fall
|
|
|
1951
2058
|
forgetPaneAnswers(timer.deliver_pane, choices);
|
|
1952
2059
|
return;
|
|
1953
2060
|
}
|
|
2061
|
+
if (firing !== null && !claimRepeatingFallback(timer))
|
|
2062
|
+
return;
|
|
1954
2063
|
note = withReDelivered(note);
|
|
1955
2064
|
fallback = true;
|
|
1956
2065
|
}
|
|
1957
2066
|
}
|
|
1958
|
-
const
|
|
1959
|
-
|
|
2067
|
+
const named = firing === null ? `wake #${timer.id}` : `wake #${timer.id} firing #${firing}`;
|
|
2068
|
+
const prefix = `[hive ${named}${note ? `, ${note}` : ""}] ` + (fallback ? `If you already handled ${named}, ignore this.\n` : "");
|
|
1960
2069
|
const method = fallback ? DELIVERY_METHOD.ptyAfterSocketTimeout : DELIVERY_METHOD.pty;
|
|
1961
2070
|
let typedBusy;
|
|
1962
2071
|
try {
|
|
@@ -1967,9 +2076,9 @@ async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner, fall
|
|
|
1967
2076
|
typedBusy = null;
|
|
1968
2077
|
}
|
|
1969
2078
|
const recordTyped = () => bestEffortRun(`UPDATE wakes SET typed_at = strftime('%Y-%m-%d %H:%M:%f', 'now'), typed_busy = ?,
|
|
1970
|
-
typed_seen = ?, first_held_at = ?, held_at = NULL, held_reason = NULL, delivery_method = ?,
|
|
2079
|
+
typed_seen = ?, first_held_at = ?, held_at = NULL, held_reason = NULL, delivery_method = ?, delivered_by = ?,
|
|
1971
2080
|
confirmed_at = CASE WHEN socket_attempt_at IS NULL THEN NULL ELSE confirmed_at END
|
|
1972
|
-
WHERE id =
|
|
2081
|
+
WHERE id = ? AND (? IS NULL OR fire_count = ?)`, typedBusy, typedSeen, firstHeldAt, method, deliveredByForFiring(timer), timer.id, firing, firing);
|
|
1973
2082
|
const box = cacheEntry(timer.deliver_pane, choices).box;
|
|
1974
2083
|
const strandedTextWouldHold = box !== undefined && box !== null && box.state !== "unknown";
|
|
1975
2084
|
let pasted = false;
|
|
@@ -1978,15 +2087,27 @@ async function deliver(timer, note, choices, typedSeen, firstHeldAt, owner, fall
|
|
|
1978
2087
|
pasted = true;
|
|
1979
2088
|
if (strandedTextWouldHold)
|
|
1980
2089
|
recordTyped();
|
|
1981
|
-
}, undefined, owner
|
|
2090
|
+
}, undefined, owner || firing !== null
|
|
2091
|
+
? () => {
|
|
2092
|
+
if (owner)
|
|
2093
|
+
requireStillOwned(owner.rowId, owner.identity, `wake #${timer.id} delivery`);
|
|
2094
|
+
if (firing !== null)
|
|
2095
|
+
requireCurrentFiring(timer.id, firing);
|
|
2096
|
+
}
|
|
2097
|
+
: undefined);
|
|
1982
2098
|
}
|
|
1983
2099
|
catch (err) {
|
|
1984
|
-
if (fallback && !pasted) {
|
|
1985
|
-
bestEffortRun("UPDATE wakes SET delivery_method = ? WHERE id = ? AND typed_at IS NULL", DELIVERY_METHOD.socket, timer.id);
|
|
2100
|
+
if (fallback && !pasted && firing === null) {
|
|
2101
|
+
bestEffortRun("UPDATE wakes SET delivery_method = ?, delivered_by = ? WHERE id = ? AND typed_at IS NULL", DELIVERY_METHOD.socket, deliveredByForFiring(timer), timer.id);
|
|
1986
2102
|
}
|
|
2103
|
+
if (err instanceof FiringSuperseded)
|
|
2104
|
+
return;
|
|
2105
|
+
if (firing !== null && !pasted)
|
|
2106
|
+
dropRepeatingFiring(timer, firing, err);
|
|
1987
2107
|
if (!(err instanceof PaneOwnershipLost))
|
|
1988
2108
|
throw err;
|
|
1989
|
-
|
|
2109
|
+
if (firing === null || pasted)
|
|
2110
|
+
recordLostAfterClaim(timer, pasted, err.message, firing);
|
|
1990
2111
|
return;
|
|
1991
2112
|
}
|
|
1992
2113
|
finally {
|
|
@@ -2011,9 +2132,11 @@ async function fallbackClaimed(timer, snapshot, choices) {
|
|
|
2011
2132
|
const decision = deliverable(timer, snapshot, choices);
|
|
2012
2133
|
if (!decision.ok)
|
|
2013
2134
|
return false;
|
|
2014
|
-
const claimed =
|
|
2015
|
-
|
|
2016
|
-
|
|
2135
|
+
const claimed = timer.delivery_method === DELIVERY_METHOD.socketRepeating
|
|
2136
|
+
? claimRepeatingFallback(timer)
|
|
2137
|
+
: stmt(`UPDATE wakes SET delivery_method = ?, delivered_by = ?
|
|
2138
|
+
WHERE id = ? AND ${socketAwaitingWhere()}
|
|
2139
|
+
AND NOT EXISTS (${promptEvidenceSql("wakes.socket_attempt_at").replace("MIN(created_at)", "1")})`).run(DELIVERY_METHOD.ptyAfterSocketTimeout, deliveredByForFiring(timer), timer.id).changes === 1;
|
|
2017
2140
|
if (!claimed)
|
|
2018
2141
|
return false;
|
|
2019
2142
|
await deliver(timer, withReDelivered(timer.socket_delivery_note), choices, decision.typedSeen, decision.firstHeldAt, decision.owner, true);
|
|
@@ -2022,7 +2145,7 @@ async function fallbackClaimed(timer, snapshot, choices) {
|
|
|
2022
2145
|
async function retryUnconfirmedSocketWakes(snapshot, choices) {
|
|
2023
2146
|
const awaiting = stmt(`SELECT wakes.*, ${DELIVER_ROW_COLUMNS}
|
|
2024
2147
|
FROM wakes ${DELIVER_SOCKET_JOIN}
|
|
2025
|
-
WHERE ${socketAwaitingWhere("wakes.")}
|
|
2148
|
+
WHERE ((${socketAwaitingWhere("wakes.")}) OR (${repeatingSocketAwaitingWhere("wakes.")}))
|
|
2026
2149
|
AND wakes.socket_attempt_at >= datetime('now', ?)`).all(LOG_RETENTION);
|
|
2027
2150
|
for (const timer of awaiting) {
|
|
2028
2151
|
if (!socketFallbackDue(timer))
|
package/dist/tmux.js
CHANGED
|
@@ -123,7 +123,11 @@ function tmuxWithin(timeoutMs, ...args) {
|
|
|
123
123
|
throw new TmuxError(`tmux ${args[0]} failed${detail ? `: ${detail}` : ""}`, detail ?? "");
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
|
-
const NOTHING_THERE = /no server running|
|
|
126
|
+
const NOTHING_THERE = /no server running|no current target|can't find (pane|window|session)/;
|
|
127
|
+
// A connect failure is an empty server only when the errno proves nothing listens; EACCES/EPERM
|
|
128
|
+
// (a sandboxed caller) and any unreadable reason mean nobody answered.
|
|
129
|
+
const CONNECT_FAILED = /error connecting to .*\(([^)]*)\)\s*$/;
|
|
130
|
+
const NO_LISTENER = /^(No such file or directory|Connection refused)$/;
|
|
127
131
|
// "no server running" is tmux ANSWERING that the socket is empty. A missing binary is nobody
|
|
128
132
|
// answering at all, and the two must never read the same to anything that reports a death.
|
|
129
133
|
export function tmuxNotInstalled(e) {
|
|
@@ -136,6 +140,9 @@ export function tmuxSaysNothingThere(e) {
|
|
|
136
140
|
return false;
|
|
137
141
|
if (e.notInstalled)
|
|
138
142
|
return true;
|
|
143
|
+
const reason = CONNECT_FAILED.exec(e.stderr)?.[1];
|
|
144
|
+
if (reason !== undefined)
|
|
145
|
+
return NO_LISTENER.test(reason);
|
|
139
146
|
return NOTHING_THERE.test(e.stderr);
|
|
140
147
|
}
|
|
141
148
|
function quietTmux(...args) {
|
|
@@ -854,7 +861,12 @@ function leadingRunIsFaint(s) {
|
|
|
854
861
|
faint = false;
|
|
855
862
|
continue;
|
|
856
863
|
}
|
|
857
|
-
for (
|
|
864
|
+
for (let i = 0; i < params.length; i++) {
|
|
865
|
+
const p = params[i];
|
|
866
|
+
if (p === "38" || p === "48" || p === "58") {
|
|
867
|
+
i += params[i + 1] === "5" ? 2 : params[i + 1] === "2" ? 4 : 0;
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
858
870
|
if (p === "0")
|
|
859
871
|
faint = false;
|
|
860
872
|
else if (p === "2")
|
|
@@ -922,19 +934,42 @@ function classifyInputBox(rows, promptRowIndex, boxBottom) {
|
|
|
922
934
|
.slice(0, TAIL_LINE_CHARS);
|
|
923
935
|
return { state: text === "" ? "empty" : dim ? "ghost" : "pending", text };
|
|
924
936
|
}
|
|
925
|
-
|
|
937
|
+
const SNAPSHOT_ROWS = 10;
|
|
938
|
+
const SNAPSHOT_ROW_CHARS = 120;
|
|
939
|
+
const snapshotRow = (row) => stripControlBytes(stripSgr(row))
|
|
940
|
+
.trimEnd()
|
|
941
|
+
.replace(/─{4,}/g, (run) => `─×${run.length}`)
|
|
942
|
+
.slice(0, SNAPSHOT_ROW_CHARS);
|
|
943
|
+
function snapshotRows(rows, anchor) {
|
|
944
|
+
if (anchor === null) {
|
|
945
|
+
return rows.map(snapshotRow).filter((row) => row !== "").slice(-SNAPSHOT_ROWS);
|
|
946
|
+
}
|
|
947
|
+
return rows.slice(anchor.top, anchor.bottom + 1).map(snapshotRow).slice(0, SNAPSHOT_ROWS);
|
|
948
|
+
}
|
|
949
|
+
function promptSgrOf(rows, anchor) {
|
|
950
|
+
if (anchor === null || anchor.prompt === null)
|
|
951
|
+
return "";
|
|
952
|
+
const row = rows[anchor.prompt];
|
|
953
|
+
const after = row.slice(row.indexOf(PROMPT_GLYPH_NBSP) + PROMPT_GLYPH_NBSP.length);
|
|
954
|
+
return (/^(?:\x1b\[[0-9;]*m)*/.exec(after)?.[0] ?? "").replace(/\x1b/g, "").slice(0, 60);
|
|
955
|
+
}
|
|
956
|
+
export function inputBoxSnapshot(target) {
|
|
926
957
|
try {
|
|
927
958
|
const raw = tmux("capture-pane", "-p", "-e", "-t", target, "-S", `-${tailCaptureLines()}`);
|
|
928
959
|
const rows = raw.split("\n");
|
|
929
|
-
const
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
960
|
+
const anchor = findInputBox(rows);
|
|
961
|
+
const box = anchor === null
|
|
962
|
+
? null
|
|
963
|
+
: anchor.prompt === null
|
|
964
|
+
? { state: "unknown", text: "" }
|
|
965
|
+
: classifyInputBox(rows, anchor.prompt, anchor.bottom);
|
|
966
|
+
return { box, rows: snapshotRows(rows, anchor), promptSgr: promptSgrOf(rows, anchor) };
|
|
933
967
|
}
|
|
934
968
|
catch {
|
|
935
969
|
return null;
|
|
936
970
|
}
|
|
937
971
|
}
|
|
972
|
+
export const inputBoxState = (target) => inputBoxSnapshot(target)?.box ?? null;
|
|
938
973
|
const NOISE_ROW = /^[\s\u2500-\u257F\u2580-\u259F\u2800-\u28FF]*$/;
|
|
939
974
|
const FOOTER_KEEP = /esc to interrupt|\d+ (?:shell|background|task|monitor|local agent|team)s?\b|remote dynamic workflow|cloud session|ultraplan needs your input/i;
|
|
940
975
|
const OSC_SEQUENCE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
package/dist/tools/agents.js
CHANGED
|
@@ -10,7 +10,8 @@ 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";
|
|
@@ -1126,7 +1127,7 @@ export function registerAgents(server) {
|
|
|
1126
1127
|
};
|
|
1127
1128
|
}));
|
|
1128
1129
|
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.",
|
|
1130
|
+
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
1131
|
annotations: {
|
|
1131
1132
|
readOnlyHint: false,
|
|
1132
1133
|
destructiveHint: true,
|
|
@@ -1151,6 +1152,7 @@ export function registerAgents(server) {
|
|
|
1151
1152
|
let outgoing = args.text ?? "";
|
|
1152
1153
|
const tag = senderTag(project.id, currentActor());
|
|
1153
1154
|
let shortened = null;
|
|
1155
|
+
let boxBefore = null;
|
|
1154
1156
|
const withShortened = (receipt) => shortened === null
|
|
1155
1157
|
? receipt
|
|
1156
1158
|
: {
|
|
@@ -1183,6 +1185,32 @@ export function registerAgents(server) {
|
|
|
1183
1185
|
"turning a text call into a keys call. Tab and newline are the only control characters " +
|
|
1184
1186
|
'allowed. To send an actual keystroke on purpose, use keys instead (e.g. keys: ["C-c"]).');
|
|
1185
1187
|
}
|
|
1188
|
+
const quiet = await sendQuietLeadMessage({
|
|
1189
|
+
projectId: project.id,
|
|
1190
|
+
fromActor: currentActor(),
|
|
1191
|
+
target: agent,
|
|
1192
|
+
text: args.text,
|
|
1193
|
+
submit: args.submit !== false,
|
|
1194
|
+
});
|
|
1195
|
+
if (quiet !== null) {
|
|
1196
|
+
if (quiet.sent) {
|
|
1197
|
+
confirmQueenWrite("agent_send", project.id, args, { sent: true, agent_id: agent.id }, auditIdentity);
|
|
1198
|
+
}
|
|
1199
|
+
if (args.wait_ms == null)
|
|
1200
|
+
return quiet;
|
|
1201
|
+
await sleep(Math.min(Math.max(args.wait_ms, 250), 10000));
|
|
1202
|
+
try {
|
|
1203
|
+
if (observeOwnership(agent) !== "live")
|
|
1204
|
+
throw new Error("not live");
|
|
1205
|
+
return { ...quiet, tail: capturePane(target, 15) };
|
|
1206
|
+
}
|
|
1207
|
+
catch {
|
|
1208
|
+
return {
|
|
1209
|
+
...quiet,
|
|
1210
|
+
tail_note: "The terminal tail could not be read after the wait (the pane may have died or changed owner).",
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1186
1214
|
if (!screenClassifiable(agent.command)) {
|
|
1187
1215
|
return {
|
|
1188
1216
|
agent_id: agent.id,
|
|
@@ -1235,7 +1263,9 @@ export function registerAgents(server) {
|
|
|
1235
1263
|
};
|
|
1236
1264
|
}
|
|
1237
1265
|
if (submitting) {
|
|
1238
|
-
const
|
|
1266
|
+
const snapshot = readInputBox(sendClassifier, target);
|
|
1267
|
+
const box = snapshot?.box ?? null;
|
|
1268
|
+
boxBefore = inputBoxBefore(snapshot);
|
|
1239
1269
|
if (holdsHumanInput(box)) {
|
|
1240
1270
|
return {
|
|
1241
1271
|
agent_id: agent.id,
|
|
@@ -1319,12 +1349,13 @@ export function registerAgents(server) {
|
|
|
1319
1349
|
sent: true,
|
|
1320
1350
|
...tailField,
|
|
1321
1351
|
...inputBoxField(agent.command, target),
|
|
1352
|
+
...(boxBefore === null ? {} : { input_box_before: boxBefore }),
|
|
1322
1353
|
});
|
|
1323
1354
|
}
|
|
1324
1355
|
return withShortened({ agent_id: agent.id, name: agent.name, sent: true });
|
|
1325
1356
|
}));
|
|
1326
1357
|
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
|
|
1358
|
+
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
1359
|
annotations: {
|
|
1329
1360
|
readOnlyHint: false,
|
|
1330
1361
|
destructiveHint: false,
|
|
@@ -1349,6 +1380,9 @@ export function registerAgents(server) {
|
|
|
1349
1380
|
chars: row.text.length,
|
|
1350
1381
|
created_at: row.created_at,
|
|
1351
1382
|
text: row.text,
|
|
1383
|
+
...(row.delivery_status === null
|
|
1384
|
+
? {}
|
|
1385
|
+
: Object.fromEntries(DELIVERY_FIELDS.map((field) => [field, row[field]]))),
|
|
1352
1386
|
};
|
|
1353
1387
|
}));
|
|
1354
1388
|
server.registerTool("agent_output", {
|
|
@@ -1528,3 +1562,21 @@ export function registerAgents(server) {
|
|
|
1528
1562
|
};
|
|
1529
1563
|
}));
|
|
1530
1564
|
}
|
|
1565
|
+
function inputBoxBefore(snapshot) {
|
|
1566
|
+
if (snapshot === null)
|
|
1567
|
+
return { state: "unreadable" };
|
|
1568
|
+
const { box, rows, promptSgr } = snapshot;
|
|
1569
|
+
const sgr = promptSgr ? { prompt_sgr: promptSgr } : {};
|
|
1570
|
+
const withRows = rows === undefined ? {} : { rows };
|
|
1571
|
+
if (box === null)
|
|
1572
|
+
return { state: "not_found", ...withRows, ...sgr };
|
|
1573
|
+
if (box.state === "ghost")
|
|
1574
|
+
return { state: "ghost", ...sgr };
|
|
1575
|
+
return { state: box.state, text: box.text, ...withRows, ...sgr };
|
|
1576
|
+
}
|
|
1577
|
+
function readInputBox(classifier, target) {
|
|
1578
|
+
if (classifier.inputBoxSnapshot)
|
|
1579
|
+
return classifier.inputBoxSnapshot(target);
|
|
1580
|
+
const box = classifier.inputBoxState(target);
|
|
1581
|
+
return box === null ? null : { box };
|
|
1582
|
+
}
|
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/package.json
CHANGED