@shanesaravia/hive 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +16 -1
- package/node_modules/@hive/shared/dist/status.js +9 -0
- package/node_modules/@hive/shared/dist/types.d.ts +162 -1
- package/node_modules/@hive/shared/dist/types.js +27 -0
- package/package.json +1 -1
- package/packages/server/dist/agents/agentDiscovery.js +64 -0
- package/packages/server/dist/api/rest.js +474 -14
- package/packages/server/dist/api/ws.js +66 -2
- package/packages/server/dist/control/missionQuiesce.js +66 -0
- package/packages/server/dist/health/deriveAlerts.js +8 -0
- package/packages/server/dist/index.js +26 -2
- package/packages/server/dist/loops/loopCommand.js +56 -0
- package/packages/server/dist/loops/loopNoop.js +38 -0
- package/packages/server/dist/loops/loopScheduler.js +58 -0
- package/packages/server/dist/loops/loopStore.js +118 -0
- package/packages/server/dist/loops/monitors.js +38 -0
- package/packages/server/dist/messages/attachmentStore.js +92 -0
- package/packages/server/dist/messages/messagesStore.js +97 -33
- package/packages/server/dist/reviews/reviewDiff.js +47 -0
- package/packages/server/dist/roster/replyAsk.js +62 -0
- package/packages/server/dist/roster/rosterBuilder.js +41 -5
- package/packages/server/dist/roster/workerIdentity.js +46 -9
- package/packages/server/dist/skills/skillDiscovery.js +28 -4
- package/packages/server/dist/terminals/claudeStreamClient.js +90 -0
- package/packages/server/dist/terminals/codexAppServerClient.js +195 -0
- package/packages/server/dist/terminals/providerDetection.js +27 -0
- package/packages/server/dist/terminals/terminalCapability.js +45 -0
- package/packages/server/dist/terminals/terminalFeatures.js +11 -0
- package/packages/server/dist/terminals/terminalObservability.js +21 -0
- package/packages/server/dist/terminals/terminalRuntime.js +125 -0
- package/packages/server/dist/terminals/terminalStream.js +30 -0
- package/packages/server/dist/transcripts/transcriptReader.js +345 -0
- package/packages/web/dist/assets/index-DWjqiitn.js +17 -0
- package/packages/web/dist/assets/index-rd4RnLqj.css +2 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-BpEYVjCF.css +0 -2
- package/packages/web/dist/assets/index-rIAIJyuF.js +0 -12
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import Database from "better-sqlite3";
|
|
3
3
|
import { config } from "../config.js";
|
|
4
|
+
import { stripLoopInstructions, stripNoopMarker } from "../loops/loopNoop.js";
|
|
4
5
|
const DEFAULT_PAGE_SIZE = 30;
|
|
5
6
|
const MAX_PAGE_SIZE = 100;
|
|
6
7
|
export const MODEL_CONTEXT_MESSAGE_LIMIT = 12;
|
|
@@ -16,6 +17,14 @@ function outputText(job) {
|
|
|
16
17
|
}
|
|
17
18
|
return undefined;
|
|
18
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Stable suffix derived from the text itself, so re-running a sync over the
|
|
22
|
+
* same content is a no-op while genuinely different content lands under its own
|
|
23
|
+
* id instead of overwriting what is already on screen.
|
|
24
|
+
*/
|
|
25
|
+
export function contentKey(text) {
|
|
26
|
+
return createHash("sha1").update(text).digest("hex").slice(0, 12);
|
|
27
|
+
}
|
|
19
28
|
function timestamp(value) {
|
|
20
29
|
const parsed = value ? Date.parse(value) : NaN;
|
|
21
30
|
return Number.isNaN(parsed) ? Date.now() : parsed;
|
|
@@ -28,6 +37,8 @@ function toMessage(row) {
|
|
|
28
37
|
createdAt: row.created_at,
|
|
29
38
|
jobId: row.job_id ?? "",
|
|
30
39
|
...(row.kind === "update" ? { kind: "update" } : {}),
|
|
40
|
+
...(row.attachments ? { attachments: JSON.parse(row.attachments) } : {}),
|
|
41
|
+
...(row.noop ? { noop: true } : {}),
|
|
31
42
|
};
|
|
32
43
|
}
|
|
33
44
|
/** Indexed, paginated transcript storage. History is never prompt context by default. */
|
|
@@ -46,7 +57,9 @@ export class MessagesStore {
|
|
|
46
57
|
text TEXT NOT NULL,
|
|
47
58
|
created_at INTEGER NOT NULL,
|
|
48
59
|
job_id TEXT,
|
|
49
|
-
kind TEXT
|
|
60
|
+
kind TEXT,
|
|
61
|
+
attachments TEXT,
|
|
62
|
+
noop INTEGER
|
|
50
63
|
);
|
|
51
64
|
CREATE INDEX IF NOT EXISTS idx_mission_messages_page
|
|
52
65
|
ON mission_messages (mission_id, created_at DESC, id DESC);
|
|
@@ -57,6 +70,14 @@ export class MessagesStore {
|
|
|
57
70
|
this.db.exec("ALTER TABLE mission_messages ADD COLUMN kind TEXT");
|
|
58
71
|
}
|
|
59
72
|
catch { /* already present */ }
|
|
73
|
+
try {
|
|
74
|
+
this.db.exec("ALTER TABLE mission_messages ADD COLUMN attachments TEXT");
|
|
75
|
+
}
|
|
76
|
+
catch { /* already present */ }
|
|
77
|
+
try {
|
|
78
|
+
this.db.exec("ALTER TABLE mission_messages ADD COLUMN noop INTEGER");
|
|
79
|
+
}
|
|
80
|
+
catch { /* already present */ }
|
|
60
81
|
}
|
|
61
82
|
close() {
|
|
62
83
|
this.db.close();
|
|
@@ -72,26 +93,20 @@ export class MessagesStore {
|
|
|
72
93
|
createdAt: input.createdAt,
|
|
73
94
|
jobId: input.jobId,
|
|
74
95
|
...(input.kind ? { kind: input.kind } : {}),
|
|
96
|
+
...(input.attachments?.length ? { attachments: input.attachments } : {}),
|
|
75
97
|
};
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
81
|
-
mission_id = excluded.mission_id,
|
|
82
|
-
text = excluded.text,
|
|
83
|
-
created_at = excluded.created_at,
|
|
84
|
-
job_id = excluded.job_id
|
|
85
|
-
`)
|
|
86
|
-
.run({ ...message, kind: message.kind ?? null, missionId: input.missionId, jobId: input.jobId || null });
|
|
98
|
+
// Callers key on the job (`<jobId>:user`), so a retried turn can collide
|
|
99
|
+
// with a message already on screen. Append rather than rewrite it, and
|
|
100
|
+
// report the id the text actually landed under.
|
|
101
|
+
const id = this.appendSynced({ ...message, missionId: input.missionId, jobId: input.jobId || "" });
|
|
87
102
|
this.notify();
|
|
88
|
-
return message;
|
|
103
|
+
return { ...message, id };
|
|
89
104
|
}
|
|
90
105
|
page(missionId, options = {}) {
|
|
91
106
|
const limit = Math.min(Math.max(options.limit ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE);
|
|
92
107
|
const rows = this.db
|
|
93
108
|
.prepare(`
|
|
94
|
-
SELECT id, mission_id, role, text, created_at, job_id, kind
|
|
109
|
+
SELECT id, mission_id, role, text, created_at, job_id, kind, attachments, noop
|
|
95
110
|
FROM mission_messages
|
|
96
111
|
WHERE mission_id = @missionId
|
|
97
112
|
AND (@before IS NULL OR created_at < @before)
|
|
@@ -147,7 +162,7 @@ export class MessagesStore {
|
|
|
147
162
|
id: `${jobId}:user`,
|
|
148
163
|
missionId,
|
|
149
164
|
role: "user",
|
|
150
|
-
text: job.intent,
|
|
165
|
+
text: stripLoopInstructions(job.intent),
|
|
151
166
|
createdAt: timestamp(job.createdAt),
|
|
152
167
|
jobId,
|
|
153
168
|
});
|
|
@@ -160,21 +175,35 @@ export class MessagesStore {
|
|
|
160
175
|
// Blocked turns (e.g. awaiting acceptance) also carry a compressed
|
|
161
176
|
// output.result — the transcript text wins there too when it says more.
|
|
162
177
|
const fullReply = (job.state === "done" || job.state === "blocked") && job.lastText && job.lastText.length > (output?.length ?? 0) ? job.lastText : undefined;
|
|
163
|
-
const
|
|
178
|
+
const rawResult = fullReply
|
|
164
179
|
?? output
|
|
165
180
|
?? (job.state === "failed" ? `⚠️ This turn failed: ${job.detail ?? "the session exited before producing a reply"}` : undefined)
|
|
166
|
-
?? (job.state === "blocked" ? job.lastText ?? job.needs ?? job.detail : undefined)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
181
|
+
?? (job.state === "blocked" ? job.lastText ?? job.needs ?? job.detail : undefined);
|
|
182
|
+
// A looping agent ends a pass that found nothing with a marker line.
|
|
183
|
+
// It is bookkeeping, so it is split off here and never reaches the
|
|
184
|
+
// transcript — only the flag it sets, which folds the run away.
|
|
185
|
+
const quiet = rawResult ? stripNoopMarker(rawResult) : undefined;
|
|
186
|
+
const result = quiet?.text || rawResult;
|
|
187
|
+
// Mid-turn permission prompts: the job stays "working" but tempo flips
|
|
188
|
+
// to blocked with the ask in needs (e.g. "approve Bash: …"). This is a
|
|
189
|
+
// passing event, not the turn's reply — it gets its own appended line
|
|
190
|
+
// rather than sitting in `:assistant`, where the real reply would later
|
|
191
|
+
// overwrite it and the prompt would appear to vanish.
|
|
192
|
+
const prompt = job.tempo === "blocked" && job.needs ? `🔐 ${job.needs}` : undefined;
|
|
170
193
|
// What the model said on the way — narration between tool calls — as
|
|
171
194
|
// compact updates, so the conversation shows the work as it goes
|
|
172
195
|
// rather than a single reply at the end.
|
|
173
196
|
const updates = (job.progressTexts ?? []).filter((text) => text !== result);
|
|
174
197
|
const turnStartedAt = timestamp(job.createdAt);
|
|
175
|
-
|
|
198
|
+
// Keyed by content, not by position: the filter above can drop an entry
|
|
199
|
+
// on a later sync, and index-keyed ids would then shift every following
|
|
200
|
+
// bubble onto different text.
|
|
201
|
+
updates.forEach((text, index) => this.appendSynced({ id: `${jobId}:update:${contentKey(text)}`, missionId, role: "assistant", text, createdAt: turnStartedAt + 1 + index, jobId, kind: "update" }));
|
|
202
|
+
if (prompt) {
|
|
203
|
+
this.appendSynced({ id: `${jobId}:prompt:${contentKey(prompt)}`, missionId, role: "assistant", text: prompt, createdAt: timestamp(job.updatedAt ?? job.createdAt), jobId, kind: "update" });
|
|
204
|
+
}
|
|
176
205
|
if (result) {
|
|
177
|
-
this.
|
|
206
|
+
this.appendSynced({
|
|
178
207
|
id: `${jobId}:assistant`,
|
|
179
208
|
missionId,
|
|
180
209
|
role: "assistant",
|
|
@@ -183,6 +212,10 @@ export class MessagesStore {
|
|
|
183
212
|
jobId,
|
|
184
213
|
});
|
|
185
214
|
}
|
|
215
|
+
// After the reply is written, so the run folds as a whole rather than
|
|
216
|
+
// leaving the newest message behind.
|
|
217
|
+
if (quiet?.noop)
|
|
218
|
+
this.markQuietRun(jobId);
|
|
186
219
|
}
|
|
187
220
|
});
|
|
188
221
|
sync();
|
|
@@ -191,31 +224,62 @@ export class MessagesStore {
|
|
|
191
224
|
onChange(listener) {
|
|
192
225
|
this.listeners.add(listener);
|
|
193
226
|
}
|
|
194
|
-
/**
|
|
227
|
+
/**
|
|
228
|
+
* Insert that follows mission relinking but never rewrites text — and never
|
|
229
|
+
* rewrites `created_at` either, which is the sort key: moving it would slide
|
|
230
|
+
* an already-read bubble to a different place in the transcript.
|
|
231
|
+
*/
|
|
195
232
|
insertSynced(message) {
|
|
196
233
|
this.db
|
|
197
234
|
.prepare(`
|
|
198
|
-
INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id, kind)
|
|
199
|
-
VALUES (@id, @missionId, @role, @text, @createdAt, @jobId, @kind)
|
|
235
|
+
INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id, kind, attachments, noop)
|
|
236
|
+
VALUES (@id, @missionId, @role, @text, @createdAt, @jobId, @kind, @attachments, @noop)
|
|
200
237
|
ON CONFLICT(id) DO UPDATE SET
|
|
201
238
|
mission_id = excluded.mission_id,
|
|
202
|
-
created_at = excluded.created_at,
|
|
203
239
|
job_id = excluded.job_id
|
|
204
240
|
`)
|
|
205
|
-
.run({ ...message, kind: message.kind ?? null });
|
|
241
|
+
.run({ ...message, kind: message.kind ?? null, attachments: message.attachments?.length ? JSON.stringify(message.attachments) : null, noop: message.noop ? 1 : null });
|
|
242
|
+
return message.id;
|
|
206
243
|
}
|
|
207
|
-
|
|
244
|
+
/**
|
|
245
|
+
* The append-only write, and the reason a bubble the reader has already seen
|
|
246
|
+
* cannot be swapped for a different one.
|
|
247
|
+
*
|
|
248
|
+
* Syncs run repeatedly over the same live job, and its text moves as the turn
|
|
249
|
+
* progresses — a compressed `output.result` arrives before the transcript's
|
|
250
|
+
* fuller `lastText`. Only one kind of in-place edit stays truthful: pure
|
|
251
|
+
* growth, where what is on screen is a prefix of what is arriving, so the
|
|
252
|
+
* message extends rather than changes. Text that genuinely differs is a
|
|
253
|
+
* different thing to say and lands as its own appended message.
|
|
254
|
+
*/
|
|
255
|
+
appendSynced(message) {
|
|
256
|
+
const existing = this.db
|
|
257
|
+
.prepare("SELECT text FROM mission_messages WHERE id = ?")
|
|
258
|
+
.get(message.id);
|
|
259
|
+
if (existing && existing.text !== message.text && !message.text.startsWith(existing.text)) {
|
|
260
|
+
return this.insertSynced({ ...message, id: `${message.id}:${contentKey(message.text)}` });
|
|
261
|
+
}
|
|
208
262
|
this.db
|
|
209
263
|
.prepare(`
|
|
210
|
-
INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id, kind)
|
|
211
|
-
VALUES (@id, @missionId, @role, @text, @createdAt, @jobId, @kind)
|
|
264
|
+
INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id, kind, attachments, noop)
|
|
265
|
+
VALUES (@id, @missionId, @role, @text, @createdAt, @jobId, @kind, @attachments, @noop)
|
|
212
266
|
ON CONFLICT(id) DO UPDATE SET
|
|
213
267
|
mission_id = excluded.mission_id,
|
|
214
268
|
text = excluded.text,
|
|
215
|
-
created_at = excluded.created_at,
|
|
216
269
|
job_id = excluded.job_id
|
|
217
270
|
`)
|
|
218
|
-
.run({ ...message, kind: message.kind ?? null });
|
|
271
|
+
.run({ ...message, kind: message.kind ?? null, attachments: message.attachments?.length ? JSON.stringify(message.attachments) : null, noop: message.noop ? 1 : null });
|
|
272
|
+
return message.id;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Flags every message of a turn as a quiet loop pass.
|
|
276
|
+
*
|
|
277
|
+
* Display metadata only — no text is rewritten and nothing moves, so the
|
|
278
|
+
* append-only guarantee holds. The prompt is flagged alongside the reply so
|
|
279
|
+
* a run folds as one unit rather than leaving its question stranded.
|
|
280
|
+
*/
|
|
281
|
+
markQuietRun(jobId) {
|
|
282
|
+
this.db.prepare("UPDATE mission_messages SET noop = 1 WHERE job_id = ? AND noop IS NULL").run(jobId);
|
|
219
283
|
}
|
|
220
284
|
notify() {
|
|
221
285
|
for (const listener of this.listeners)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
const MAX_PATCH = 200_000;
|
|
4
|
+
function git(cwd, args) {
|
|
5
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", timeout: 5_000, maxBuffer: 2_000_000, stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
6
|
+
}
|
|
7
|
+
function gitDiff(cwd, args) {
|
|
8
|
+
try {
|
|
9
|
+
return git(cwd, ["diff", ...args]);
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
const stdout = error.stdout;
|
|
13
|
+
if (stdout !== undefined)
|
|
14
|
+
return String(stdout).trim();
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Read-only source-control evidence for a mission-controlled repository. */
|
|
19
|
+
export function reviewDiff(cwd) {
|
|
20
|
+
if (!cwd || !fs.existsSync(cwd))
|
|
21
|
+
return { available: false, stat: "", patch: "", files: [], truncated: false, error: "Repository is unavailable." };
|
|
22
|
+
try {
|
|
23
|
+
if (git(cwd, ["rev-parse", "--is-inside-work-tree"]) !== "true")
|
|
24
|
+
throw new Error("Not a Git worktree");
|
|
25
|
+
let base;
|
|
26
|
+
for (const candidate of ["origin/main", "main", "origin/master", "master"]) {
|
|
27
|
+
try {
|
|
28
|
+
base = git(cwd, ["merge-base", "HEAD", candidate]);
|
|
29
|
+
if (base)
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
catch { /* try next baseline */ }
|
|
33
|
+
}
|
|
34
|
+
const range = base ? [`${base}..HEAD`] : [];
|
|
35
|
+
const committed = gitDiff(cwd, range);
|
|
36
|
+
const working = gitDiff(cwd, ["HEAD"]);
|
|
37
|
+
const untracked = git(cwd, ["ls-files", "--others", "--exclude-standard"]).split("\n").filter(Boolean);
|
|
38
|
+
const untrackedPatches = untracked.map((file) => gitDiff(cwd, ["--no-index", "--", "/dev/null", file]));
|
|
39
|
+
const patch = [committed, working, ...untrackedPatches].filter(Boolean).join("\n");
|
|
40
|
+
const stat = [git(cwd, ["diff", "--stat", ...range]), git(cwd, ["diff", "--stat", "HEAD"]), untracked.length ? `${untracked.length} untracked file${untracked.length === 1 ? "" : "s"}` : ""].filter(Boolean).join("\n");
|
|
41
|
+
const files = [...new Set([...git(cwd, ["diff", "--name-only", ...range]).split("\n"), ...git(cwd, ["diff", "--name-only", "HEAD"]).split("\n"), ...untracked].filter(Boolean))];
|
|
42
|
+
return { available: true, base, stat, patch: patch.slice(0, MAX_PATCH), files, truncated: patch.length > MAX_PATCH };
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
return { available: false, stat: "", patch: "", files: [], truncated: false, error: error.message };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phrases that mark a reply as genuinely handing a choice back to the reader.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately narrow. A turn ending "let me know if you need anything else"
|
|
5
|
+
* is politeness, not a decision, and surfacing those as "Needs you" would
|
|
6
|
+
* teach the reader to ignore the one signal that should never be ignored.
|
|
7
|
+
*/
|
|
8
|
+
const ASK_PATTERNS = [
|
|
9
|
+
/\bfor you to decide\b/i,
|
|
10
|
+
/\byour (call|decision|choice)\b/i,
|
|
11
|
+
/\bup to you\b/i,
|
|
12
|
+
/\bsay the word\b/i,
|
|
13
|
+
/\bwhich (one )?(would|do) you (prefer|want)\b/i,
|
|
14
|
+
/\b(do|would) you want me to\b/i,
|
|
15
|
+
/\bwould you like me to\b/i,
|
|
16
|
+
/\bshall I\b/i,
|
|
17
|
+
/\bshould I\b[^?]*\?/i,
|
|
18
|
+
/\bplease (confirm|advise|choose)\b/i,
|
|
19
|
+
/\blet me know (which|whether|if you want|if you'd)\b/i,
|
|
20
|
+
/\bneeds? your (input|decision|sign-?off|approval|steer)\b/i,
|
|
21
|
+
/\bwaiting (on|for) your (call|decision|answer|input)\b/i,
|
|
22
|
+
];
|
|
23
|
+
/** Sign-offs that look like asks but close a turn rather than pausing it. */
|
|
24
|
+
const CLOSING_PATTERNS = [
|
|
25
|
+
/^\s*(anything else|any(thing)? more)\b/i,
|
|
26
|
+
/\blet me know if you (need|have|spot|want) (anything|any|something)\b/i,
|
|
27
|
+
];
|
|
28
|
+
const MAX_QUESTION_CHARS = 800;
|
|
29
|
+
/** Strips leading markdown emphasis and list markers so the ask reads as prose. */
|
|
30
|
+
function tidy(line) {
|
|
31
|
+
return line.replace(/^\s*[-*]\s+/, "").replace(/\*\*/g, "").trim();
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Finds a decision the agent handed back in the prose of its reply.
|
|
35
|
+
*
|
|
36
|
+
* Hive's decision inbox is fed by structured signals — emitted decisions,
|
|
37
|
+
* parked permissions, provider prompts — none of which fire when an agent
|
|
38
|
+
* simply finishes its turn by asking a question. Those asks were reaching the
|
|
39
|
+
* transcript with no affordance at all: no "Needs you", nothing in the inbox,
|
|
40
|
+
* nothing to answer, and a mission that looked finished while it waited.
|
|
41
|
+
*
|
|
42
|
+
* Returns the ask from the first marker line onward, so an enumerated list of
|
|
43
|
+
* choices arrives with the question that introduced it.
|
|
44
|
+
*/
|
|
45
|
+
export function detectReplyAsk(reply) {
|
|
46
|
+
if (!reply?.trim())
|
|
47
|
+
return undefined;
|
|
48
|
+
const lines = reply.split("\n");
|
|
49
|
+
const at = lines.findIndex((line) => {
|
|
50
|
+
if (CLOSING_PATTERNS.some((pattern) => pattern.test(line)))
|
|
51
|
+
return false;
|
|
52
|
+
return ASK_PATTERNS.some((pattern) => pattern.test(line));
|
|
53
|
+
});
|
|
54
|
+
if (at === -1)
|
|
55
|
+
return undefined;
|
|
56
|
+
// From the marker to the end: an ask is routinely a header followed by the
|
|
57
|
+
// options it introduces, and the header alone would be unanswerable.
|
|
58
|
+
const question = lines.slice(at).map(tidy).filter(Boolean).join("\n").trim();
|
|
59
|
+
if (!question)
|
|
60
|
+
return undefined;
|
|
61
|
+
return { question: question.length > MAX_QUESTION_CHARS ? `${question.slice(0, MAX_QUESTION_CHARS).trimEnd()}…` : question };
|
|
62
|
+
}
|
|
@@ -3,7 +3,9 @@ import { allPlanTasks } from "../plans/plansStore.js";
|
|
|
3
3
|
import { deriveChangedFiles, reconcilePlanWorkers } from "../plans/planReconcile.js";
|
|
4
4
|
import { config } from "../config.js";
|
|
5
5
|
import { deriveAlerts } from "../health/deriveAlerts.js";
|
|
6
|
+
import { jobIsLive } from "../control/missionQuiesce.js";
|
|
6
7
|
import { resolveMissionWorkers } from "./workerIdentity.js";
|
|
8
|
+
import { detectReplyAsk } from "./replyAsk.js";
|
|
7
9
|
const ORCHESTRATOR_AGENT_NAME = "hive-orchestrator";
|
|
8
10
|
function dateMs(value) {
|
|
9
11
|
const parsed = value ? Date.parse(value) : NaN;
|
|
@@ -97,7 +99,20 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
97
99
|
choices: latestJob.promptChoices ?? [], impact: "The provider is waiting for this answer before the skill can continue.", createdAt: node.updatedAt, ...missionContext,
|
|
98
100
|
}]
|
|
99
101
|
: [];
|
|
100
|
-
|
|
102
|
+
// A turn that ended by asking the reader something. No structured signal
|
|
103
|
+
// fires for this — the agent simply wrote the question — so without it the
|
|
104
|
+
// mission sits looking finished while it waits for an answer.
|
|
105
|
+
const replyAsk = node.turnCompleted && latestJob && !resolved.has(`ask:${node.jobId}`)
|
|
106
|
+
? detectReplyAsk(latestJob.lastText ?? resultText(latestJob))
|
|
107
|
+
: undefined;
|
|
108
|
+
const fromReply = replyAsk && !fromEvents.length && !parked.length && !providerPrompt.length
|
|
109
|
+
? [{
|
|
110
|
+
id: `ask:${node.jobId}`, missionId: node.missionId, missionName: node.name,
|
|
111
|
+
kind: "question", question: replyAsk.question,
|
|
112
|
+
choices: [], impact: "The mission has finished its turn and is waiting on your answer.", createdAt: node.updatedAt, ...missionContext,
|
|
113
|
+
}]
|
|
114
|
+
: [];
|
|
115
|
+
return [...fromEvents, ...parked, ...providerPrompt, ...fromReply];
|
|
101
116
|
};
|
|
102
117
|
for (const [missionId, group] of groups) {
|
|
103
118
|
group.sort((a, b) => dateMs(a.job.createdAt) - dateMs(b.job.createdAt));
|
|
@@ -138,6 +153,12 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
138
153
|
}));
|
|
139
154
|
const eventJobId = (event) => event.jobId ?? jobIdBySession.get(event.sessionId);
|
|
140
155
|
const missionSummary = missions?.summaryFor(missionId, oldest.job.intent ?? "");
|
|
156
|
+
const turnCompleted = latest.job.state === "done";
|
|
157
|
+
// Every manager on this mission the provider can still act through. A
|
|
158
|
+
// mission is meant to have one; more than one means a resume forked
|
|
159
|
+
// instead of continuing, and the extras are still working the same
|
|
160
|
+
// worktree. Both the roster and the alerts need to know.
|
|
161
|
+
const liveJobIds = group.filter(({ jobId, job }) => jobIsLive(jobId, job, sessions.values())).map(({ jobId }) => jobId);
|
|
141
162
|
// One canonical person per delegated job. The resolver owns every identity
|
|
142
163
|
// decision — native ids, delegation labels, plan tasks, nested agents —
|
|
143
164
|
// and persists them so a later poll cannot re-decide them differently.
|
|
@@ -152,6 +173,7 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
152
173
|
seededNested: workerIdentity?.listNested(missionId),
|
|
153
174
|
now,
|
|
154
175
|
correlationGraceMs: workerCorrelationGraceMs,
|
|
176
|
+
liveJobIds,
|
|
155
177
|
});
|
|
156
178
|
let workers = resolved.workers;
|
|
157
179
|
if (workerIdentity && resolved.dirty) {
|
|
@@ -233,20 +255,34 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
233
255
|
// In that case the runtime is idle, not mission-complete.
|
|
234
256
|
if (activityStatus === "done")
|
|
235
257
|
activityStatus = "idle";
|
|
236
|
-
|
|
237
|
-
|
|
258
|
+
// Provably running right now, as opposed to a plan that merely claims
|
|
259
|
+
// work is open. Workers outlive the manager's own session going quiet
|
|
260
|
+
// between turns, so a mission with live workers is working — reading
|
|
261
|
+
// "Idle" while four of them report progress is simply wrong.
|
|
262
|
+
const liveWork = session?.status === "busy"
|
|
263
|
+
|| latest.job.state === "working" || latest.job.state === "busy"
|
|
264
|
+
|| workers.some((worker) => !worker.doneAt && (worker.jobState === "working" || worker.jobState === "busy"));
|
|
265
|
+
const workExpected = liveWork || Boolean(missionPlan?.phases.some((phase) => phase.status === "working" || phase.status === "reviewing" || allPlanTasks(phase.tasks).some((task) => task.status === "working" || task.status === "reviewing")));
|
|
266
|
+
if ((activityStatus === "idle" || activityStatus === "working") && workExpected && inactiveForMs >= config.stalledAfterMs) {
|
|
267
|
+
// Covers "working" too: a job record left behind by a dead process
|
|
268
|
+
// still claims to be working, and only the elapsed time known here can
|
|
269
|
+
// tell that apart from work actually in progress.
|
|
238
270
|
activityStatus = "stalled";
|
|
239
271
|
}
|
|
272
|
+
else if (activityStatus === "idle" && liveWork) {
|
|
273
|
+
// A stale plan can claim work forever, so only provably live work
|
|
274
|
+
// promotes an idle mission; a plan's claim alone must not.
|
|
275
|
+
activityStatus = "working";
|
|
276
|
+
}
|
|
240
277
|
}
|
|
241
278
|
const name = missions?.nameFor(missionId) ?? oldest.job.name ?? latest.job.name ?? missionId;
|
|
242
|
-
const turnCompleted = latest.job.state === "done";
|
|
243
279
|
const pendingDecisions = pendingDecisionsFor({ missionId, name, jobId: latest.jobId, mission, lifecycleStatus: mission.lifecycleStatus, turnCompleted, recentEvents, updatedAt });
|
|
244
280
|
// An unanswered decision is, by definition, waiting on the user. Do not let the
|
|
245
281
|
// runtime's quiet session decay it to idle/stalled while the question stands.
|
|
246
282
|
if (mission.lifecycleStatus === "active" && pendingDecisions.length && (activityStatus === "idle" || activityStatus === "stalled"))
|
|
247
283
|
activityStatus = "waiting_on_you";
|
|
248
284
|
const runStartedAt = dateMs(latest.job.createdAt) || createdAt;
|
|
249
|
-
const alerts = deriveAlerts({ lifecycle: mission.lifecycleStatus, activity: activityStatus, stale: derived.stale, turnCompleted, workers, events: recentEvents, now, turnTokens: latest.job.tokens ?? 0, runStartedAt });
|
|
285
|
+
const alerts = deriveAlerts({ lifecycle: mission.lifecycleStatus, activity: activityStatus, stale: derived.stale, turnCompleted, workers, events: recentEvents, now, turnTokens: latest.job.tokens ?? 0, runStartedAt, liveJobIds });
|
|
250
286
|
orchestrators.push({
|
|
251
287
|
missionId,
|
|
252
288
|
threadId: missionId,
|
|
@@ -288,9 +288,12 @@ export class WorkerIdentityTable {
|
|
|
288
288
|
* produced a different binding. Version 2 fixed an earlier delegation
|
|
289
289
|
* adopting a later, unrelated launch that was still inside the pairing window,
|
|
290
290
|
* which merged two workers into one actor and left the second delegation's
|
|
291
|
-
* label stranded as a third.
|
|
291
|
+
* label stranded as a third. Version 5 stopped a launch under a still-live
|
|
292
|
+
* sibling job being read as a replacement, which merged the workers of
|
|
293
|
+
* concurrent managers into one person — and clears the records those bindings
|
|
294
|
+
* already wrote.
|
|
292
295
|
*/
|
|
293
|
-
export const WORKER_IDENTITY_RESOLVER_VERSION = "
|
|
296
|
+
export const WORKER_IDENTITY_RESOLVER_VERSION = "5";
|
|
294
297
|
/** Persists resolved identities so they survive the bounded event ring. */
|
|
295
298
|
export class WorkerIdentityStore {
|
|
296
299
|
db;
|
|
@@ -400,6 +403,7 @@ export function resolveMissionWorkers(input) {
|
|
|
400
403
|
const events = expandMultiTargetEvents(input.events);
|
|
401
404
|
const correlationGraceMs = input.correlationGraceMs ?? 8_000;
|
|
402
405
|
const pairWindowMs = input.pairWindowMs ?? 30_000;
|
|
406
|
+
const liveJobIds = new Set(input.liveJobIds ?? []);
|
|
403
407
|
const planTasksInput = input.planTasks ?? [];
|
|
404
408
|
const table = new WorkerIdentityTable(missionId, input.seeded);
|
|
405
409
|
// 1. Hook lifecycle evidence, keyed by the provider's own agent id.
|
|
@@ -468,16 +472,27 @@ export function resolveMissionWorkers(input) {
|
|
|
468
472
|
continue;
|
|
469
473
|
const prior = nativeEvidence.get(nativeId);
|
|
470
474
|
// A stop with no observed launch is usually a nested or background task
|
|
471
|
-
// notification: evidence, but not enough to invent a person
|
|
472
|
-
|
|
475
|
+
// notification: evidence, but not enough to invent a person — unless a
|
|
476
|
+
// persisted record already claims this id. Then the person exists and the
|
|
477
|
+
// launch has merely scrolled out of the event ring, which on a busy
|
|
478
|
+
// mission takes only a couple of minutes: 216 events separated one
|
|
479
|
+
// observed worker's start from its stop, and the ring holds 200. Dropping
|
|
480
|
+
// the stop there left `lastStoppedAt` unset for good, and a worker that
|
|
481
|
+
// had finished a quarter of an hour earlier read "Working" indefinitely.
|
|
482
|
+
const seededRecord = isStop && !prior ? table.find({ nativeId }) : undefined;
|
|
483
|
+
if (isStop && !prior && !seededRecord)
|
|
473
484
|
continue;
|
|
474
485
|
const label = labelOf(event) ?? prior?.label ?? nativeId;
|
|
475
486
|
nativeEvidence.set(nativeId, {
|
|
476
487
|
nativeId,
|
|
477
488
|
jobId,
|
|
478
489
|
label: prior && prior.label !== prior.nativeId ? prior.label : label,
|
|
479
|
-
startedAt: Math.min(prior?.startedAt ?? event.ts, event.ts),
|
|
480
|
-
|
|
490
|
+
startedAt: Math.min(prior?.startedAt ?? seededRecord?.startedAt ?? event.ts, event.ts),
|
|
491
|
+
// A stop must never be read as a start. Where the launch is out of view,
|
|
492
|
+
// the record's own last start is the honest answer; inventing one at the
|
|
493
|
+
// stop's timestamp would make the worker look as though it began at the
|
|
494
|
+
// moment it ended.
|
|
495
|
+
restartedAt: isStart ? event.ts : prior?.restartedAt ?? seededRecord?.lastStartedAt ?? event.ts,
|
|
481
496
|
doneAt: isStop ? event.ts : isStart ? undefined : prior?.doneAt,
|
|
482
497
|
updatedAt: event.ts,
|
|
483
498
|
running: isStart,
|
|
@@ -615,7 +630,16 @@ export function resolveMissionWorkers(input) {
|
|
|
615
630
|
const previousStopped = previousStoppedAt !== Infinity && event.ts >= previousStoppedAt;
|
|
616
631
|
// A retry on a later turn arrives under a new job while the old attempt's
|
|
617
632
|
// job has simply ended; that is a replacement too, stop or no stop.
|
|
618
|
-
|
|
633
|
+
//
|
|
634
|
+
// "Under a new job" only means the old attempt ended if the old job did.
|
|
635
|
+
// Two managers live on one mission at the same time — a resume that forked
|
|
636
|
+
// rather than continued — delegate the same plan task within seconds of
|
|
637
|
+
// each other under two different job ids, and reading the second as a
|
|
638
|
+
// retry of the first merged four separate agents across four sessions into
|
|
639
|
+
// one person: a board refresher's work was credited to the reviewer, and
|
|
640
|
+
// the mission reported a worker had done something that worker never did.
|
|
641
|
+
// A job still able to act has not ended, and its worker is a sibling.
|
|
642
|
+
const laterJob = Boolean(pairable && named?.lastJobId && pairable.jobId !== named.lastJobId && !liveJobIds.has(named.lastJobId));
|
|
619
643
|
const replacing = alreadyLaunched && !sameLaunch && !reported && taskStillOpen && (previousStopped || laterJob);
|
|
620
644
|
const adopt = pairable && (!alreadyLaunched || sameLaunch || replacing) ? pairable : undefined;
|
|
621
645
|
if (adopt)
|
|
@@ -774,8 +798,21 @@ export function resolveMissionWorkers(input) {
|
|
|
774
798
|
const closedTasks = taskIds.length && planTasks.length ? planTasks.filter((task) => taskIds.includes(task.id) && (task.status === "completed" || task.status === "cancelled")) : [];
|
|
775
799
|
const taskClosed = closedTasks.length && !openTask ? Math.max(...closedTasks.map((task) => task.updatedAt ?? 0)) || stoppedAt || now : undefined;
|
|
776
800
|
// The stop-settle applies only where nothing else can ever speak for the
|
|
777
|
-
// worker
|
|
778
|
-
|
|
801
|
+
// worker. That is a question about *this* worker, not about the mission:
|
|
802
|
+
// the reason a plan suppresses the settle is that the orchestrator is
|
|
803
|
+
// speaking for its workers through it, and it speaks for a worker only via
|
|
804
|
+
// a task that worker owns. A worker with no task of its own, in a mission
|
|
805
|
+
// that has a plan, had nothing that could ever retire it — no task to
|
|
806
|
+
// close, no settle allowed — so a `SubagentStop` left it reading as merely
|
|
807
|
+
// paused for the rest of the mission. One observed worker sat that way for
|
|
808
|
+
// 27 minutes beside a manager that had been idle for 26 of them.
|
|
809
|
+
// Either direction of the binding counts: a task id the record has claimed,
|
|
810
|
+
// or a task that names this worker itself. Reading only the first missed
|
|
811
|
+
// every worker the plan had been reconciled onto rather than delegated to.
|
|
812
|
+
const ownNames = new Set([record.canonicalId, ...record.nativeIds, ...record.labels].map((name) => name.toLowerCase()));
|
|
813
|
+
const spokenForByPlan = planTasks.some((task) => (taskIds.length > 0 && taskIds.includes(task.id))
|
|
814
|
+
|| (task.workerId ? ownNames.has(task.workerId.toLowerCase()) : false));
|
|
815
|
+
const settledStop = stoppedAt !== undefined && !spokenForByPlan && !awaitingBackgroundWork && now - stoppedAt >= WORKER_STOP_SETTLE_MS ? stoppedAt : undefined;
|
|
779
816
|
// The orchestrator's report closes a worker for good. The provider may
|
|
780
817
|
// still start that agent again — a manager checking on a delayed reply
|
|
781
818
|
// does exactly this, twice in one observed run — but those are follow-ups
|
|
@@ -50,17 +50,41 @@ function collectPluginSkills(root) {
|
|
|
50
50
|
}
|
|
51
51
|
return output;
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* A mission's recorded repository can be a directory that no longer exists —
|
|
55
|
+
* reaching a terminal state reclaims its worktree. The project half of
|
|
56
|
+
* discovery is unavailable then, but the user's own skills are not, so this
|
|
57
|
+
* degrades to them rather than failing outright. Failing threw out ten usable
|
|
58
|
+
* skills over a missing project directory, and, because `sendToMission`
|
|
59
|
+
* discovers skills before it falls back to a live repository, made messaging
|
|
60
|
+
* such a mission impossible.
|
|
61
|
+
*/
|
|
62
|
+
function projectSkills(cwd, provider) {
|
|
63
|
+
let project;
|
|
64
|
+
try {
|
|
65
|
+
project = requireWorkingDirectory(cwd);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
return provider === "codex"
|
|
71
|
+
? collect(path.join(project, ".codex", "skills"), "project")
|
|
72
|
+
: [
|
|
73
|
+
...collect(path.join(project, ".claude", "skills"), "project"),
|
|
74
|
+
...collect(path.join(project, ".claude", "commands"), "project"),
|
|
75
|
+
];
|
|
76
|
+
}
|
|
53
77
|
export function discoverSkills(cwd, provider = "claude") {
|
|
54
|
-
const project = requireWorkingDirectory(cwd);
|
|
55
78
|
const claudeConfig = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
56
79
|
const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
80
|
+
// Project entries lead, so the dedup below keeps them over a user skill of
|
|
81
|
+
// the same name.
|
|
57
82
|
const all = provider === "codex" ? [
|
|
58
|
-
...
|
|
83
|
+
...projectSkills(cwd, provider),
|
|
59
84
|
...collect(path.join(codexHome, "skills"), "user"),
|
|
60
85
|
...collectPluginSkills(path.join(codexHome, "plugins", "cache")),
|
|
61
86
|
] : [
|
|
62
|
-
...
|
|
63
|
-
...collect(path.join(project, ".claude", "commands"), "project"),
|
|
87
|
+
...projectSkills(cwd, provider),
|
|
64
88
|
...collect(path.join(claudeConfig, "skills"), "user"),
|
|
65
89
|
...collect(path.join(claudeConfig, "commands"), "user"),
|
|
66
90
|
];
|