@shanesaravia/hive 0.3.0 → 0.5.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 (42) hide show
  1. package/CHANGELOG.md +44 -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 +170 -1
  5. package/node_modules/@hive/shared/dist/types.js +35 -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 +574 -25
  9. package/packages/server/dist/api/ws.js +66 -2
  10. package/packages/server/dist/control/missionQuiesce.js +70 -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 +76 -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 +119 -31
  20. package/packages/server/dist/missions/missionsStore.js +1 -1
  21. package/packages/server/dist/missions/reopenOnWork.js +7 -0
  22. package/packages/server/dist/paths/pathResolver.js +112 -17
  23. package/packages/server/dist/plans/plansStore.js +32 -2
  24. package/packages/server/dist/reviews/reviewDiff.js +47 -0
  25. package/packages/server/dist/roster/replyAsk.js +62 -0
  26. package/packages/server/dist/roster/rosterBuilder.js +41 -5
  27. package/packages/server/dist/roster/workerIdentity.js +135 -30
  28. package/packages/server/dist/skills/skillDiscovery.js +28 -4
  29. package/packages/server/dist/terminals/claudeStreamClient.js +90 -0
  30. package/packages/server/dist/terminals/codexAppServerClient.js +195 -0
  31. package/packages/server/dist/terminals/providerDetection.js +27 -0
  32. package/packages/server/dist/terminals/terminalCapability.js +45 -0
  33. package/packages/server/dist/terminals/terminalFeatures.js +11 -0
  34. package/packages/server/dist/terminals/terminalObservability.js +21 -0
  35. package/packages/server/dist/terminals/terminalRuntime.js +125 -0
  36. package/packages/server/dist/terminals/terminalStream.js +30 -0
  37. package/packages/server/dist/transcripts/transcriptReader.js +345 -0
  38. package/packages/web/dist/assets/index-C1T22qkK.css +2 -0
  39. package/packages/web/dist/assets/index-CVc04VBT.js +18 -0
  40. package/packages/web/dist/index.html +2 -2
  41. package/packages/web/dist/assets/index-BpEYVjCF.css +0 -2
  42. package/packages/web/dist/assets/index-rIAIJyuF.js +0 -12
@@ -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,
@@ -131,13 +131,62 @@ const LAUNCH_NAME_GRACE_MS = 3_000;
131
131
  */
132
132
  export class WorkerIdentityTable {
133
133
  missionId;
134
+ liveJobIds;
134
135
  byCanonical = new Map();
135
136
  dirty = false;
136
- constructor(missionId, seeded = []) {
137
+ constructor(missionId, seeded = [], liveJobIds = new Set()) {
137
138
  this.missionId = missionId;
139
+ this.liveJobIds = liveJobIds;
138
140
  for (const record of seeded)
139
141
  this.byCanonical.set(record.canonicalId, { ...record, nativeIds: [...record.nativeIds], labels: [...record.labels], planTaskIds: [...record.planTaskIds], sessionIds: [...record.sessionIds] });
140
142
  }
143
+ /**
144
+ * Whether this record belongs to a manager other than the one asking, and
145
+ * so must not be found by name.
146
+ *
147
+ * Only a name can be ambiguous. A provider-native agent id is unique by
148
+ * construction and means the same worker wherever it turns up. A plan task
149
+ * id and a delegation label are words an orchestrator chose, and two
150
+ * managers live on one mission choose the same words for different people:
151
+ * on mission f7ae71c7 two of them delegated plan task `t12` to a worker they
152
+ * both called `mr-babysitter`, three minutes apart. Matched across the two,
153
+ * one manager's report retired the other manager's worker.
154
+ *
155
+ * Two conditions keep this from splitting workers that ought to merge. The
156
+ * other job must still be able to act — a job that is over is not competing
157
+ * for anything, so a task picked up on a later turn still finds the worker
158
+ * whose task it is. And the worker must still be running: one that has
159
+ * finished is nobody's to guard, and adopting its task is exactly the retry
160
+ * this table exists to recognise.
161
+ */
162
+ ownedElsewhere(record, jobId) {
163
+ if (!jobId || !record.lastJobId || record.lastJobId === jobId)
164
+ return false;
165
+ return this.liveJobIds.has(record.lastJobId) && !record.doneAt;
166
+ }
167
+ /**
168
+ * A canonical id no other record has taken.
169
+ *
170
+ * A native id is unique already. A label is not, and two live managers can
171
+ * hand the same one to two different workers — where the table used to merge
172
+ * them it now keeps them apart, and keying both on the label would silently
173
+ * drop the first from the floor. The label stays the worker's display name
174
+ * either way; only the key it is filed under gains the owning job.
175
+ */
176
+ freeCanonicalId(label, jobId) {
177
+ if (!this.byCanonical.has(label))
178
+ return label;
179
+ const scoped = `${label}#${(jobId ?? "").slice(0, 8)}`;
180
+ if (jobId && !this.byCanonical.has(scoped))
181
+ return scoped;
182
+ for (let suffix = 2; suffix <= this.byCanonical.size + 2; suffix += 1) {
183
+ const candidate = `${jobId ? scoped : label}#${suffix}`;
184
+ if (!this.byCanonical.has(candidate))
185
+ return candidate;
186
+ }
187
+ // Unreachable: more candidates are tried above than there are records.
188
+ return `${label}#${this.byCanonical.size + 3}`;
189
+ }
141
190
  /** Flags a change made by a later pass, so the record set is persisted. */
142
191
  markDirty() { this.dirty = true; }
143
192
  /**
@@ -165,15 +214,17 @@ export class WorkerIdentityTable {
165
214
  if (match)
166
215
  return match;
167
216
  }
217
+ // Names, unlike native ids, can belong to a live sibling manager.
218
+ const mine = (record) => !this.ownedElsewhere(record, ref.jobId);
168
219
  const task = norm(ref.taskId);
169
220
  if (task) {
170
- const match = this.records().find((record) => record.planTaskIds.some((id) => id.toLowerCase() === task));
221
+ const match = this.records().find((record) => mine(record) && record.planTaskIds.some((id) => id.toLowerCase() === task));
171
222
  if (match)
172
223
  return match;
173
224
  }
174
225
  const label = norm(ref.label);
175
226
  if (label) {
176
- const match = this.records().find((record) => record.labels.some((item) => item.toLowerCase() === label));
227
+ const match = this.records().find((record) => mine(record) && record.labels.some((item) => item.toLowerCase() === label));
177
228
  if (match)
178
229
  return match;
179
230
  }
@@ -185,13 +236,16 @@ export class WorkerIdentityTable {
185
236
  }
186
237
  /** Merge this evidence into an existing person, or create one. */
187
238
  bind(ref, into, options = {}) {
188
- const existing = into ?? (options.allowLabelMerge === false ? this.find({ nativeId: ref.nativeId, taskId: ref.taskId }) : this.find(ref));
239
+ const existing = into ?? (options.allowLabelMerge === false ? this.find({ nativeId: ref.nativeId, taskId: ref.taskId, jobId: ref.jobId }) : this.find(ref));
189
240
  if (!existing) {
190
241
  // A native id is the stable, provider-owned name and is preferred as the
191
242
  // canonical id. A label-only worker (Codex, or a delegation whose launch
192
243
  // hook never arrived) is keyed by its label, scoped to this mission
193
- // rather than to a single parent job so a resumed turn reuses it.
194
- const canonicalId = ref.nativeId?.trim() || ref.label?.trim();
244
+ // rather than to a single parent job so a resumed turn reuses it — and
245
+ // disambiguated by job only where a live sibling already holds the name.
246
+ const native = ref.nativeId?.trim();
247
+ const label = ref.label?.trim();
248
+ const canonicalId = native || (label ? this.freeCanonicalId(label, ref.jobId) : undefined);
195
249
  if (!canonicalId)
196
250
  throw new Error("worker identity requires a native id or a label");
197
251
  const record = { canonicalId, missionId: this.missionId, nativeIds: [], labels: [], planTaskIds: [], sessionIds: [], startedAt: ref.at, lastJobId: ref.jobId };
@@ -273,14 +327,26 @@ export class WorkerIdentityTable {
273
327
  this.dirty = true;
274
328
  return existing;
275
329
  }
276
- /** Canonical id for any alias, or undefined when nothing has claimed it. */
277
- canonicalFor(value) {
330
+ /**
331
+ * Canonical id for any alias, or undefined when nothing has claimed it.
332
+ *
333
+ * `jobId` is the manager speaking. Where two of them are live and have used
334
+ * the same word for two different workers, the speaker's own worker is the
335
+ * one it means — without that, a manager reporting its worker finished
336
+ * retired whichever record happened to be found first, and the wrong avatar
337
+ * left the floor.
338
+ */
339
+ canonicalFor(value, jobId) {
278
340
  if (!value)
279
341
  return undefined;
280
342
  const wanted = norm(value);
281
343
  if (!wanted)
282
344
  return undefined;
283
- return this.records().find((record) => [record.canonicalId, ...record.nativeIds, ...record.labels, ...record.planTaskIds].some((item) => item.toLowerCase() === wanted))?.canonicalId;
345
+ const matches = this.records().filter((record) => [record.canonicalId, ...record.nativeIds, ...record.labels, ...record.planTaskIds].some((item) => item.toLowerCase() === wanted));
346
+ if (!matches.length)
347
+ return undefined;
348
+ const own = jobId ? matches.find((record) => record.lastJobId === jobId) : undefined;
349
+ return (own ?? matches.find((record) => !this.ownedElsewhere(record, jobId)) ?? matches[0]).canonicalId;
284
350
  }
285
351
  }
286
352
  /**
@@ -288,9 +354,13 @@ export class WorkerIdentityTable {
288
354
  * produced a different binding. Version 2 fixed an earlier delegation
289
355
  * adopting a later, unrelated launch that was still inside the pairing window,
290
356
  * which merged two workers into one actor and left the second delegation's
291
- * label stranded as a third.
357
+ * label stranded as a third. Version 5 stopped a launch under a still-live
358
+ * sibling job being read as a replacement, which merged the workers of
359
+ * concurrent managers into one person — and clears the records those bindings
360
+ * already wrote. Version 6 stopped a plan task or delegation label finding a
361
+ * live sibling manager's worker at all.
292
362
  */
293
- export const WORKER_IDENTITY_RESOLVER_VERSION = "4";
363
+ export const WORKER_IDENTITY_RESOLVER_VERSION = "6";
294
364
  /** Persists resolved identities so they survive the bounded event ring. */
295
365
  export class WorkerIdentityStore {
296
366
  db;
@@ -358,12 +428,13 @@ export class WorkerIdentityStore {
358
428
  .run(JSON.stringify(record), Date.now(), missionId, canonicalId);
359
429
  }
360
430
  /** Canonical worker id for any alias, label, or plan task id. */
361
- canonicalFor(missionId, value) {
431
+ canonicalFor(missionId, value, jobId) {
362
432
  const wanted = value?.trim().toLowerCase();
363
433
  if (!wanted)
364
434
  return undefined;
365
- return this.list(missionId).find((record) => [record.canonicalId, ...record.nativeIds, ...record.labels, ...record.planTaskIds]
366
- .some((item) => item.trim().toLowerCase() === wanted))?.canonicalId;
435
+ const matches = this.list(missionId).filter((record) => [record.canonicalId, ...record.nativeIds, ...record.labels, ...record.planTaskIds]
436
+ .some((item) => item.trim().toLowerCase() === wanted));
437
+ return (jobId ? matches.find((record) => record.lastJobId === jobId) : undefined)?.canonicalId ?? matches[0]?.canonicalId;
367
438
  }
368
439
  removeMission(missionId) {
369
440
  this.db.prepare("DELETE FROM mission_workers WHERE mission_id = ?").run(missionId);
@@ -400,8 +471,9 @@ export function resolveMissionWorkers(input) {
400
471
  const events = expandMultiTargetEvents(input.events);
401
472
  const correlationGraceMs = input.correlationGraceMs ?? 8_000;
402
473
  const pairWindowMs = input.pairWindowMs ?? 30_000;
474
+ const liveJobIds = new Set(input.liveJobIds ?? []);
403
475
  const planTasksInput = input.planTasks ?? [];
404
- const table = new WorkerIdentityTable(missionId, input.seeded);
476
+ const table = new WorkerIdentityTable(missionId, input.seeded, liveJobIds);
405
477
  // 1. Hook lifecycle evidence, keyed by the provider's own agent id.
406
478
  const nativeEvidence = new Map();
407
479
  // An agent launched by a worker is that worker's activity, not a new person
@@ -468,16 +540,27 @@ export function resolveMissionWorkers(input) {
468
540
  continue;
469
541
  const prior = nativeEvidence.get(nativeId);
470
542
  // 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)
543
+ // notification: evidence, but not enough to invent a person — unless a
544
+ // persisted record already claims this id. Then the person exists and the
545
+ // launch has merely scrolled out of the event ring, which on a busy
546
+ // mission takes only a couple of minutes: 216 events separated one
547
+ // observed worker's start from its stop, and the ring holds 200. Dropping
548
+ // the stop there left `lastStoppedAt` unset for good, and a worker that
549
+ // had finished a quarter of an hour earlier read "Working" indefinitely.
550
+ const seededRecord = isStop && !prior ? table.find({ nativeId }) : undefined;
551
+ if (isStop && !prior && !seededRecord)
473
552
  continue;
474
553
  const label = labelOf(event) ?? prior?.label ?? nativeId;
475
554
  nativeEvidence.set(nativeId, {
476
555
  nativeId,
477
556
  jobId,
478
557
  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,
558
+ startedAt: Math.min(prior?.startedAt ?? seededRecord?.startedAt ?? event.ts, event.ts),
559
+ // A stop must never be read as a start. Where the launch is out of view,
560
+ // the record's own last start is the honest answer; inventing one at the
561
+ // stop's timestamp would make the worker look as though it began at the
562
+ // moment it ended.
563
+ restartedAt: isStart ? event.ts : prior?.restartedAt ?? seededRecord?.lastStartedAt ?? event.ts,
481
564
  doneAt: isStop ? event.ts : isStart ? undefined : prior?.doneAt,
482
565
  updatedAt: event.ts,
483
566
  running: isStart,
@@ -530,7 +613,7 @@ export function resolveMissionWorkers(input) {
530
613
  // worker there is nothing a name could change, so it binds immediately as it
531
614
  // always has; it is two delegations reaching for the same anonymous launch
532
615
  // that need the description to arrive first.
533
- const contenders = delegations.filter((event) => !table.find({ label: event.targetWorker?.trim() || undefined, taskId: identityTask(event) })?.nativeIds.length).length;
616
+ const contenders = delegations.filter((event) => !table.find({ label: event.targetWorker?.trim() || undefined, taskId: identityTask(event), jobId: eventJobId(event) })?.nativeIds.length).length;
534
617
  // Decide nothing while a name may still be arriving. Deferring the whole
535
618
  // pass rather than the individual launch matters: pairing sees every launch
536
619
  // at once either way, so waiting cannot change which delegation wins a
@@ -538,7 +621,7 @@ export function resolveMissionWorkers(input) {
538
621
  const waitingForNames = contenders > 1
539
622
  && launchOrder.some((launch) => !launchIsNamed(launch.nativeId) && now - launch.startedAt < LAUNCH_NAME_GRACE_MS);
540
623
  const namedLaunches = waitingForNames ? [] : launchOrder;
541
- const needsLaunch = (event) => !table.find({ label: event.targetWorker?.trim() || undefined, taskId: identityTask(event) })?.nativeIds.length;
624
+ const needsLaunch = (event) => !table.find({ label: event.targetWorker?.trim() || undefined, taskId: identityTask(event), jobId: eventJobId(event) })?.nativeIds.length;
542
625
  const pairing = pairDelegationsByName({
543
626
  delegations: delegations.filter(needsLaunch),
544
627
  launches: namedLaunches.filter((launch) => !claimed.has(launch.nativeId)),
@@ -568,7 +651,7 @@ export function resolveMissionWorkers(input) {
568
651
  return false;
569
652
  if (pairing.has(other))
570
653
  return false;
571
- const otherNamed = table.find({ label: other.targetWorker?.trim() || undefined, taskId: identityTask(other) });
654
+ const otherNamed = table.find({ label: other.targetWorker?.trim() || undefined, taskId: identityTask(other), jobId });
572
655
  if (otherNamed?.nativeIds.length)
573
656
  return false;
574
657
  return Math.abs(startedAt - other.ts) < Math.abs(startedAt - event.ts);
@@ -586,7 +669,7 @@ export function resolveMissionWorkers(input) {
586
669
  // An already-known label or plan task wins over the launch: a retried or
587
670
  // replaced task keeps its person, and the fresh provider id joins it as
588
671
  // another alias instead of taking a second desk.
589
- const named = table.find({ label, taskId: identityTask(event) });
672
+ const named = table.find({ label, taskId: identityTask(event), jobId });
590
673
  const known = named ?? (pairable ? table.find({ nativeId: pairable.nativeId }) : undefined);
591
674
  // A delegation that already has its worker must not swallow a later,
592
675
  // unrelated launch just because it is still inside the pairing window —
@@ -615,7 +698,16 @@ export function resolveMissionWorkers(input) {
615
698
  const previousStopped = previousStoppedAt !== Infinity && event.ts >= previousStoppedAt;
616
699
  // A retry on a later turn arrives under a new job while the old attempt's
617
700
  // job has simply ended; that is a replacement too, stop or no stop.
618
- const laterJob = Boolean(pairable && named?.lastJobId && pairable.jobId !== named.lastJobId);
701
+ //
702
+ // "Under a new job" only means the old attempt ended if the old job did.
703
+ // Two managers live on one mission at the same time — a resume that forked
704
+ // rather than continued — delegate the same plan task within seconds of
705
+ // each other under two different job ids, and reading the second as a
706
+ // retry of the first merged four separate agents across four sessions into
707
+ // one person: a board refresher's work was credited to the reviewer, and
708
+ // the mission reported a worker had done something that worker never did.
709
+ // A job still able to act has not ended, and its worker is a sibling.
710
+ const laterJob = Boolean(pairable && named?.lastJobId && pairable.jobId !== named.lastJobId && !liveJobIds.has(named.lastJobId));
619
711
  const replacing = alreadyLaunched && !sameLaunch && !reported && taskStillOpen && (previousStopped || laterJob);
620
712
  const adopt = pairable && (!alreadyLaunched || sameLaunch || replacing) ? pairable : undefined;
621
713
  if (adopt)
@@ -643,7 +735,7 @@ export function resolveMissionWorkers(input) {
643
735
  // still running. Merging by description exists for a retry — a fresh id for
644
736
  // work whose previous attempt has stopped or been reported — and
645
737
  // orchestrators reuse descriptions for parallel workers.
646
- const sameName = label ? table.find({ label }) : undefined;
738
+ const sameName = label ? table.find({ label, jobId: native.jobId }) : undefined;
647
739
  const sameNameLive = Boolean(sameName?.nativeIds.length) && sameName.nativeIds.some((id) => { const evidence = nativeEvidence.get(id); return evidence ? !evidence.doneAt : false; })
648
740
  && ![sameName.canonicalId, ...sameName.nativeIds, ...sameName.labels, ...sameName.planTaskIds].some((value) => reportedRefs.has(norm(value) ?? ""));
649
741
  const ref = { nativeId: native.nativeId, label, jobId: native.jobId, sessionId: native.sessionId, at: native.startedAt };
@@ -667,7 +759,7 @@ export function resolveMissionWorkers(input) {
667
759
  // it, and nobody when the manager did.
668
760
  const canonical = event.toolName === "Agent"
669
761
  ? (event.workerRef?.parentAgentId ? table.canonicalFor(event.workerRef.parentAgentId) : undefined)
670
- : table.canonicalFor(event.workerRef?.agentId) ?? table.canonicalFor(event.targetWorker);
762
+ : table.canonicalFor(event.workerRef?.agentId) ?? table.canonicalFor(event.targetWorker, eventJobId(event));
671
763
  if (canonical)
672
764
  backgroundedAt.set(canonical, Math.max(backgroundedAt.get(canonical) ?? 0, event.ts));
673
765
  }
@@ -679,7 +771,7 @@ export function resolveMissionWorkers(input) {
679
771
  for (const event of events) {
680
772
  if (event.source !== "custom")
681
773
  continue;
682
- let canonical = table.canonicalFor(event.targetWorker) ?? table.canonicalFor(event.targetTask);
774
+ let canonical = table.canonicalFor(event.targetWorker, eventJobId(event)) ?? table.canonicalFor(event.targetTask, eventJobId(event));
683
775
  if (!canonical)
684
776
  continue;
685
777
  // A delegation whose launch was never paired sits as an unpublished label
@@ -774,8 +866,21 @@ export function resolveMissionWorkers(input) {
774
866
  const closedTasks = taskIds.length && planTasks.length ? planTasks.filter((task) => taskIds.includes(task.id) && (task.status === "completed" || task.status === "cancelled")) : [];
775
867
  const taskClosed = closedTasks.length && !openTask ? Math.max(...closedTasks.map((task) => task.updatedAt ?? 0)) || stoppedAt || now : undefined;
776
868
  // 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;
869
+ // worker. That is a question about *this* worker, not about the mission:
870
+ // the reason a plan suppresses the settle is that the orchestrator is
871
+ // speaking for its workers through it, and it speaks for a worker only via
872
+ // a task that worker owns. A worker with no task of its own, in a mission
873
+ // that has a plan, had nothing that could ever retire it — no task to
874
+ // close, no settle allowed — so a `SubagentStop` left it reading as merely
875
+ // paused for the rest of the mission. One observed worker sat that way for
876
+ // 27 minutes beside a manager that had been idle for 26 of them.
877
+ // Either direction of the binding counts: a task id the record has claimed,
878
+ // or a task that names this worker itself. Reading only the first missed
879
+ // every worker the plan had been reconciled onto rather than delegated to.
880
+ const ownNames = new Set([record.canonicalId, ...record.nativeIds, ...record.labels].map((name) => name.toLowerCase()));
881
+ const spokenForByPlan = planTasks.some((task) => (taskIds.length > 0 && taskIds.includes(task.id))
882
+ || (task.workerId ? ownNames.has(task.workerId.toLowerCase()) : false));
883
+ const settledStop = stoppedAt !== undefined && !spokenForByPlan && !awaitingBackgroundWork && now - stoppedAt >= WORKER_STOP_SETTLE_MS ? stoppedAt : undefined;
779
884
  // The orchestrator's report closes a worker for good. The provider may
780
885
  // still start that agent again — a manager checking on a delayed reply
781
886
  // does exactly this, twice in one observed run — but those are follow-ups
@@ -824,7 +929,7 @@ export function resolveMissionWorkers(input) {
824
929
  // native id is the one that survives.
825
930
  const delegationWaiting = !record.published && !record.delegated && record.nativeIds.length > 0
826
931
  && delegations.some((other) => eventJobId(other) === jobId && needsLaunch(other)
827
- && table.find({ label: other.targetWorker?.trim() || undefined, taskId: identityTask(other) })?.published)
932
+ && table.find({ label: other.targetWorker?.trim() || undefined, taskId: identityTask(other), jobId: eventJobId(other) })?.published)
828
933
  && now - startedAt < correlationGraceMs;
829
934
  if (mayYetPair || delegationWaiting || (!corroborated && now - startedAt < correlationGraceMs))
830
935
  continue;
@@ -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
  ];
@@ -0,0 +1,90 @@
1
+ import { spawn } from "node:child_process";
2
+ import { Duplex } from "node:stream";
3
+ export function claudeStreamArguments(options) {
4
+ const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"];
5
+ if (options.resumeSessionId)
6
+ args.push("--resume", options.resumeSessionId);
7
+ if (options.model)
8
+ args.push("--model", options.model);
9
+ return args;
10
+ }
11
+ /** Reproducible lifecycle spike; production Claude --bg remains follow-up only. */
12
+ export class ClaudeStreamClient {
13
+ stream;
14
+ interruptProcess;
15
+ terminateProcess;
16
+ buffer = "";
17
+ closed = false;
18
+ listeners = new Set();
19
+ constructor(stream, interruptProcess = () => undefined, terminateProcess = () => undefined) {
20
+ this.stream = stream;
21
+ this.interruptProcess = interruptProcess;
22
+ this.terminateProcess = terminateProcess;
23
+ stream.setEncoding("utf8");
24
+ stream.on("data", (chunk) => this.consume(chunk));
25
+ stream.on("close", () => this.finish());
26
+ stream.on("error", (error) => this.emit({ type: "hive/error", message: error.message }));
27
+ }
28
+ static spawn(options, command = "claude") {
29
+ const child = spawn(command, claudeStreamArguments(options), { cwd: options.cwd, stdio: ["pipe", "pipe", "pipe"] });
30
+ child.stderr.resume();
31
+ return new ClaudeStreamClient(new ChildDuplex(child), () => { child.kill("SIGINT"); }, () => { child.kill("SIGTERM"); });
32
+ }
33
+ send(text, sessionId) {
34
+ if (this.closed)
35
+ throw new Error("Claude stream client is closed");
36
+ const message = { type: "user", message: { role: "user", content: text } };
37
+ if (sessionId)
38
+ message.session_id = sessionId;
39
+ this.stream.write(`${JSON.stringify(message)}\n`);
40
+ }
41
+ onEvent(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
42
+ interrupt() { if (!this.closed)
43
+ this.interruptProcess(); }
44
+ endInput() { if (!this.closed)
45
+ this.stream.end(); }
46
+ close() { if (!this.closed) {
47
+ this.closed = true;
48
+ this.stream.end();
49
+ this.terminateProcess();
50
+ } }
51
+ consume(chunk) {
52
+ this.buffer += chunk;
53
+ let newline;
54
+ while ((newline = this.buffer.indexOf("\n")) >= 0) {
55
+ const line = this.buffer.slice(0, newline).trim();
56
+ this.buffer = this.buffer.slice(newline + 1);
57
+ if (!line)
58
+ continue;
59
+ try {
60
+ const event = JSON.parse(line);
61
+ if (event && typeof event.type === "string")
62
+ this.emit(event);
63
+ }
64
+ catch {
65
+ this.emit({ type: "hive/invalid-json", line });
66
+ }
67
+ }
68
+ }
69
+ finish() { if (!this.closed) {
70
+ this.closed = true;
71
+ this.emit({ type: "hive/connection/closed" });
72
+ } }
73
+ emit(event) { for (const listener of this.listeners)
74
+ listener(event); }
75
+ }
76
+ class ChildDuplex extends Duplex {
77
+ child;
78
+ constructor(child) {
79
+ super();
80
+ this.child = child;
81
+ child.stdout.on("data", (chunk) => this.push(chunk));
82
+ child.stdout.on("end", () => this.push(null));
83
+ child.stdout.on("error", (error) => this.destroy(error));
84
+ child.stdin.on("error", (error) => this.destroy(error));
85
+ child.once("exit", () => this.push(null));
86
+ }
87
+ _read() { }
88
+ _write(chunk, encoding, callback) { this.child.stdin.write(chunk, encoding, callback); }
89
+ _final(callback) { this.child.stdin.end(callback); }
90
+ }