@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +16 -1
  3. package/node_modules/@hive/shared/dist/status.js +9 -0
  4. package/node_modules/@hive/shared/dist/types.d.ts +162 -1
  5. package/node_modules/@hive/shared/dist/types.js +27 -0
  6. package/package.json +1 -1
  7. package/packages/server/dist/agents/agentDiscovery.js +64 -0
  8. package/packages/server/dist/api/rest.js +474 -14
  9. package/packages/server/dist/api/ws.js +66 -2
  10. package/packages/server/dist/control/missionQuiesce.js +66 -0
  11. package/packages/server/dist/health/deriveAlerts.js +8 -0
  12. package/packages/server/dist/index.js +26 -2
  13. package/packages/server/dist/loops/loopCommand.js +56 -0
  14. package/packages/server/dist/loops/loopNoop.js +38 -0
  15. package/packages/server/dist/loops/loopScheduler.js +58 -0
  16. package/packages/server/dist/loops/loopStore.js +118 -0
  17. package/packages/server/dist/loops/monitors.js +38 -0
  18. package/packages/server/dist/messages/attachmentStore.js +92 -0
  19. package/packages/server/dist/messages/messagesStore.js +97 -33
  20. package/packages/server/dist/reviews/reviewDiff.js +47 -0
  21. package/packages/server/dist/roster/replyAsk.js +62 -0
  22. package/packages/server/dist/roster/rosterBuilder.js +41 -5
  23. package/packages/server/dist/roster/workerIdentity.js +46 -9
  24. package/packages/server/dist/skills/skillDiscovery.js +28 -4
  25. package/packages/server/dist/terminals/claudeStreamClient.js +90 -0
  26. package/packages/server/dist/terminals/codexAppServerClient.js +195 -0
  27. package/packages/server/dist/terminals/providerDetection.js +27 -0
  28. package/packages/server/dist/terminals/terminalCapability.js +45 -0
  29. package/packages/server/dist/terminals/terminalFeatures.js +11 -0
  30. package/packages/server/dist/terminals/terminalObservability.js +21 -0
  31. package/packages/server/dist/terminals/terminalRuntime.js +125 -0
  32. package/packages/server/dist/terminals/terminalStream.js +30 -0
  33. package/packages/server/dist/transcripts/transcriptReader.js +345 -0
  34. package/packages/web/dist/assets/index-DWjqiitn.js +17 -0
  35. package/packages/web/dist/assets/index-rd4RnLqj.css +2 -0
  36. package/packages/web/dist/index.html +2 -2
  37. package/packages/web/dist/assets/index-BpEYVjCF.css +0 -2
  38. 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
- this.db
77
- .prepare(`
78
- INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id, kind)
79
- VALUES (@id, @missionId, @role, @text, @createdAt, @jobId, @kind)
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 result = fullReply
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
- // Mid-turn permission prompts: the job stays "working" but tempo
168
- // flips to blocked with the ask in needs (e.g. "approve Bash: …").
169
- ?? (job.tempo === "blocked" && job.needs ? `🔐 ${job.needs}` : undefined);
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
- updates.forEach((text, index) => this.upsertSynced({ id: `${jobId}:update:${index}`, missionId, role: "assistant", text, createdAt: turnStartedAt + 1 + index, jobId, kind: "update" }));
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.upsertSynced({
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
- /** Upsert that follows mission relinking but never overwrites existing text. */
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
- upsertSynced(message) {
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
- return [...fromEvents, ...parked, ...providerPrompt];
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
- const workExpected = session?.status === "busy" || latest.job.state === "working" || latest.job.state === "busy" || workers.some((worker) => !worker.doneAt && (worker.jobState === "working" || worker.jobState === "busy")) || Boolean(missionPlan?.phases.some((phase) => phase.status === "working" || phase.status === "reviewing" || allPlanTasks(phase.tasks).some((task) => task.status === "working" || task.status === "reviewing")));
237
- if (activityStatus === "idle" && workExpected && inactiveForMs >= config.stalledAfterMs) {
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 = "4";
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
- if (isStop && !prior)
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
- restartedAt: isStart ? event.ts : prior?.restartedAt ?? event.ts,
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
- const laterJob = Boolean(pairable && named?.lastJobId && pairable.jobId !== named.lastJobId);
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: a mission with no plan.
778
- const settledStop = stoppedAt !== undefined && !planTasks.length && !awaitingBackgroundWork && now - stoppedAt >= WORKER_STOP_SETTLE_MS ? stoppedAt : undefined;
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
- ...collect(path.join(project, ".codex", "skills"), "project"),
83
+ ...projectSkills(cwd, provider),
59
84
  ...collect(path.join(codexHome, "skills"), "user"),
60
85
  ...collectPluginSkills(path.join(codexHome, "plugins", "cache")),
61
86
  ] : [
62
- ...collect(path.join(project, ".claude", "skills"), "project"),
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
  ];