@shanesaravia/hive 0.2.1 → 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 (56) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +18 -1
  3. package/node_modules/@hive/shared/dist/directStudio.d.ts +6 -0
  4. package/node_modules/@hive/shared/dist/directStudio.js +12 -0
  5. package/node_modules/@hive/shared/dist/index.d.ts +2 -0
  6. package/node_modules/@hive/shared/dist/index.js +2 -0
  7. package/node_modules/@hive/shared/dist/reviewHall.d.ts +15 -0
  8. package/node_modules/@hive/shared/dist/reviewHall.js +49 -0
  9. package/node_modules/@hive/shared/dist/status.js +9 -0
  10. package/node_modules/@hive/shared/dist/types.d.ts +191 -1
  11. package/node_modules/@hive/shared/dist/types.js +27 -0
  12. package/node_modules/@hive/shared/dist/workers.d.ts +14 -0
  13. package/node_modules/@hive/shared/dist/workers.js +22 -0
  14. package/package.json +1 -1
  15. package/packages/server/dist/agents/agentDiscovery.js +64 -0
  16. package/packages/server/dist/api/rest.js +538 -23
  17. package/packages/server/dist/api/ws.js +90 -9
  18. package/packages/server/dist/control/launcher.js +50 -11
  19. package/packages/server/dist/control/messaging.js +3 -2
  20. package/packages/server/dist/control/missionQuiesce.js +66 -0
  21. package/packages/server/dist/health/deriveAlerts.js +9 -2
  22. package/packages/server/dist/hooks/hookIngest.js +69 -10
  23. package/packages/server/dist/index.js +44 -4
  24. package/packages/server/dist/loops/loopCommand.js +56 -0
  25. package/packages/server/dist/loops/loopNoop.js +38 -0
  26. package/packages/server/dist/loops/loopScheduler.js +58 -0
  27. package/packages/server/dist/loops/loopStore.js +118 -0
  28. package/packages/server/dist/loops/monitors.js +38 -0
  29. package/packages/server/dist/messages/attachmentStore.js +92 -0
  30. package/packages/server/dist/messages/messagesStore.js +110 -33
  31. package/packages/server/dist/missions/missionsStore.js +9 -0
  32. package/packages/server/dist/missions/reopenOnWork.js +20 -0
  33. package/packages/server/dist/plans/planReconcile.js +114 -0
  34. package/packages/server/dist/plans/plansStore.js +46 -3
  35. package/packages/server/dist/reviews/reviewDiff.js +47 -0
  36. package/packages/server/dist/roster/missionReplay.js +82 -0
  37. package/packages/server/dist/roster/replyAsk.js +62 -0
  38. package/packages/server/dist/roster/rosterBuilder.js +78 -147
  39. package/packages/server/dist/roster/workerIdentity.js +923 -0
  40. package/packages/server/dist/skills/skillDiscovery.js +28 -4
  41. package/packages/server/dist/terminals/claudeStreamClient.js +90 -0
  42. package/packages/server/dist/terminals/codexAppServerClient.js +195 -0
  43. package/packages/server/dist/terminals/providerDetection.js +27 -0
  44. package/packages/server/dist/terminals/terminalCapability.js +45 -0
  45. package/packages/server/dist/terminals/terminalFeatures.js +11 -0
  46. package/packages/server/dist/terminals/terminalObservability.js +21 -0
  47. package/packages/server/dist/terminals/terminalRuntime.js +125 -0
  48. package/packages/server/dist/terminals/terminalStream.js +30 -0
  49. package/packages/server/dist/transcripts/transcriptReader.js +345 -0
  50. package/packages/server/dist/watch/jobsWatcher.js +55 -24
  51. package/packages/web/dist/assets/index-DWjqiitn.js +17 -0
  52. package/packages/web/dist/assets/index-rd4RnLqj.css +2 -0
  53. package/packages/web/dist/index.html +2 -2
  54. package/templates/agents/hive-orchestrator.md +1 -0
  55. package/packages/web/dist/assets/index-Bzle5Xla.css +0 -2
  56. package/packages/web/dist/assets/index-C6AY0vYC.js +0 -11
@@ -1,7 +1,11 @@
1
1
  import { deriveStatus, } from "@hive/shared";
2
2
  import { allPlanTasks } from "../plans/plansStore.js";
3
+ import { deriveChangedFiles, reconcilePlanWorkers } from "../plans/planReconcile.js";
3
4
  import { config } from "../config.js";
4
5
  import { deriveAlerts } from "../health/deriveAlerts.js";
6
+ import { jobIsLive } from "../control/missionQuiesce.js";
7
+ import { resolveMissionWorkers } from "./workerIdentity.js";
8
+ import { detectReplyAsk } from "./replyAsk.js";
5
9
  const ORCHESTRATOR_AGENT_NAME = "hive-orchestrator";
6
10
  function dateMs(value) {
7
11
  const parsed = value ? Date.parse(value) : NaN;
@@ -18,7 +22,7 @@ function resultText(job) {
18
22
  return undefined;
19
23
  }
20
24
  /** Joins Claude's short-lived jobs into durable, Hive-managed missions. */
21
- export function buildFleetSnapshot(sessions, jobs, events, missions, messageStore, planStore, now = Date.now()) {
25
+ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStore, planStore, now = Date.now(), workerIdentity) {
22
26
  const workerCorrelationGraceMs = 8_000;
23
27
  const orchestrators = [];
24
28
  const other = [];
@@ -95,7 +99,20 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
95
99
  choices: latestJob.promptChoices ?? [], impact: "The provider is waiting for this answer before the skill can continue.", createdAt: node.updatedAt, ...missionContext,
96
100
  }]
97
101
  : [];
98
- 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];
99
116
  };
100
117
  for (const [missionId, group] of groups) {
101
118
  group.sort((a, b) => dateMs(a.job.createdAt) - dateMs(b.job.createdAt));
@@ -124,7 +141,7 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
124
141
  .map((event) => [`${event.sessionId}:${event.jobId ?? ""}:${event.ts}:${event.phase}:${event.decisionId ?? ""}:${event.targetWorker ?? ""}:${event.detail}`, event])).values()]
125
142
  .sort((a, b) => a.ts - b.ts);
126
143
  let recentEvents = allEvents.slice(-100);
127
- let workers = group.flatMap(({ jobId, job }) => (job.fan ?? []).filter((worker) => worker.kind === "agent" || worker.kind === "subagent").map((worker) => ({
144
+ const fanWorkers = group.flatMap(({ jobId, job }) => (job.fan ?? []).filter((worker) => worker.kind === "agent" || worker.kind === "subagent").map((worker) => ({
128
145
  ...worker,
129
146
  jobId,
130
147
  jobState: job.state,
@@ -134,150 +151,48 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
134
151
  const linkedSession = sessionsByJob.get(jobId);
135
152
  return [...(job.sessionId ? [[job.sessionId, jobId]] : []), ...(linkedSession ? [[linkedSession.sessionId, jobId]] : [])];
136
153
  }));
137
- const jobStateById = new Map(group.map(({ jobId, job }) => [jobId, job.state]));
138
- const parentTurnFinished = (jobId) => ["done", "completed", "failed", "error", "cancelled", "canceled", "stopped"].includes((jobStateById.get(jobId) ?? "").toLowerCase());
139
154
  const eventJobId = (event) => event.jobId ?? jobIdBySession.get(event.sessionId);
140
- // A job's fan array is only a current view and can drop an earlier worker
141
- // as soon as a later worker starts. Rebuild the durable native roster from
142
- // hook history, keyed by Claude's real agent id, and merge fan metadata.
143
- const nativeEvidence = new Map();
144
- for (const event of allEvents) {
145
- const resolvedJobId = eventJobId(event);
146
- const raw = event.rawPayload && typeof event.rawPayload === "object" ? event.rawPayload : {};
147
- const response = raw.tool_response && typeof raw.tool_response === "object" ? raw.tool_response : {};
148
- const input = raw.tool_input && typeof raw.tool_input === "object" ? raw.tool_input : {};
149
- const launchId = typeof response.agentId === "string" ? response.agentId : typeof response.agent_id === "string" ? response.agent_id : undefined;
150
- const nativeId = launchId ?? event.targetWorker;
151
- if (!resolvedJobId || !nativeId || nativeId === "hive-orchestrator")
152
- continue;
153
- const isStart = event.hookEventName === "SubagentStart" || (event.hookEventName === "PostToolUse" && event.toolName === "Agent" && Boolean(launchId));
154
- const isStop = event.hookEventName === "SubagentStop";
155
- if (!isStart && !isStop)
156
- continue;
157
- const label = typeof response.description === "string" ? response.description : typeof input.description === "string" ? input.description : nativeId;
158
- const key = `${resolvedJobId}:${nativeId}`;
159
- const prior = nativeEvidence.get(key);
160
- // A stop without any observed launch is often a nested/background task
161
- // notification. It is evidence, but not enough to invent a person.
162
- if (isStop && !prior)
163
- continue;
164
- nativeEvidence.set(key, {
165
- id: nativeId,
166
- jobId: resolvedJobId,
167
- label: prior?.label !== prior?.id ? prior.label : label,
168
- startedAt: Math.min(prior?.startedAt ?? event.ts, event.ts),
169
- doneAt: isStop ? event.ts : isStart ? undefined : prior?.doneAt,
170
- updatedAt: event.ts,
171
- running: isStart,
172
- });
173
- }
174
- const workerByNativeKey = new Map(workers.map((worker) => [`${worker.jobId}:${worker.id}`, worker]));
175
- for (const native of nativeEvidence.values()) {
176
- const key = `${native.jobId}:${native.id}`;
177
- const fanWorker = workerByNativeKey.get(key);
178
- const turnFinished = parentTurnFinished(native.jobId);
179
- // SubagentStop is the worker's own terminal lifecycle boundary. The
180
- // manager may remain idle/open for follow-up, but that must not keep a
181
- // finished worker seated indefinitely. A later SubagentStart for the
182
- // same native id clears native.doneAt above and resumes that person.
183
- const workerFinished = Boolean(native.doneAt) || turnFinished;
184
- const merged = fanWorker ? {
185
- ...fanWorker,
186
- label: fanWorker.label || native.label,
187
- startedAt: Math.min(fanWorker.startedAt, native.startedAt),
188
- doneAt: native.doneAt ?? (turnFinished ? fanWorker.doneAt : undefined),
189
- updatedAt: Math.max(fanWorker.updatedAt ?? 0, native.updatedAt),
190
- jobState: workerFinished ? "done" : native.running ? "working" : "idle",
191
- } : {
192
- id: native.id,
193
- kind: "agent",
194
- label: native.label,
195
- startedAt: native.startedAt,
196
- doneAt: native.doneAt,
197
- updatedAt: native.updatedAt,
198
- jobId: native.jobId,
199
- jobState: workerFinished ? "done" : native.running ? "working" : "idle",
200
- };
201
- workerByNativeKey.set(key, merged);
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);
162
+ // One canonical person per delegated job. The resolver owns every identity
163
+ // decision native ids, delegation labels, plan tasks, nested agents
164
+ // and persists them so a later poll cannot re-decide them differently.
165
+ const resolved = resolveMissionWorkers({
166
+ missionId,
167
+ fanWorkers,
168
+ events: allEvents,
169
+ eventJobId,
170
+ planTasks: planStore?.get(missionId)?.phases.flatMap((phase) => allPlanTasks(phase.tasks)),
171
+ missionActive: (missionSummary?.lifecycleStatus ?? "active") === "active",
172
+ seeded: workerIdentity?.list(missionId),
173
+ seededNested: workerIdentity?.listNested(missionId),
174
+ now,
175
+ correlationGraceMs: workerCorrelationGraceMs,
176
+ liveJobIds,
177
+ });
178
+ let workers = resolved.workers;
179
+ if (workerIdentity && resolved.dirty) {
180
+ workerIdentity.save(missionId, resolved.records);
181
+ workerIdentity.saveNested(missionId, resolved.nested);
202
182
  }
203
- workers = [...workerByNativeKey.values()];
204
- // A resumed/follow-up job may address the same provider-native agent id.
205
- // The mission roster is person-oriented, so expose that id once using its
206
- // latest lifecycle state instead of creating duplicate React/desk actors
207
- // for each short-lived parent job.
208
- const workersByIdentity = new Map();
209
- for (const worker of workers.sort((a, b) => a.startedAt - b.startedAt || a.id.localeCompare(b.id))) {
210
- const prior = workersByIdentity.get(worker.id);
211
- if (!prior) {
212
- workersByIdentity.set(worker.id, worker);
213
- continue;
183
+ // Keep the board honest without waiting for the orchestrator to republish
184
+ // it: a worker that is provably running advances its task to working.
185
+ if (planStore) {
186
+ // `doneAt` is the only lifecycle signal; `jobState` is decoration.
187
+ const present = workers.filter((worker) => !worker.doneAt);
188
+ for (const binding of reconcilePlanWorkers(planStore.get(missionId), present)) {
189
+ planStore.attachWorker(missionId, binding);
190
+ if (binding.inferred)
191
+ workerIdentity?.addTaskBinding(missionId, binding.canonicalWorker, binding.taskId);
214
192
  }
215
- const priorFreshness = prior.updatedAt ?? prior.doneAt ?? prior.startedAt;
216
- const workerFreshness = worker.updatedAt ?? worker.doneAt ?? worker.startedAt;
217
- const latest = workerFreshness >= priorFreshness ? worker : prior;
218
- workersByIdentity.set(worker.id, {
219
- ...latest,
220
- label: latest.label || prior.label,
221
- startedAt: Math.min(prior.startedAt, worker.startedAt),
222
- });
223
- }
224
- workers = [...workersByIdentity.values()];
225
- // Friendly/custom worker names are presentation aliases only. Native
226
- // Claude ids remain canonical so close or repeated delegations can never
227
- // swap React identities or manufacture an extra avatar.
228
- const workerAliases = new Map();
229
- const claimedNativeWorkers = new Set();
230
- for (const event of allEvents.filter((candidate) => candidate.source === "custom" && candidate.phase === "delegating" && candidate.targetWorker)) {
231
- const resolvedJobId = eventJobId(event);
232
- if (!resolvedJobId)
233
- continue;
234
- const aliasKey = `${resolvedJobId}:${event.targetWorker}`;
235
- const candidate = workers
236
- .filter((worker) => worker.jobId === resolvedJobId && !claimedNativeWorkers.has(worker.id) && Math.abs(worker.startedAt - event.ts) <= 30_000)
237
- .sort((a, b) => Math.abs(a.startedAt - event.ts) - Math.abs(b.startedAt - event.ts))[0];
238
- workerAliases.set(aliasKey, candidate?.id ?? aliasKey);
239
- if (!candidate)
240
- continue;
241
- claimedNativeWorkers.add(candidate.id);
242
193
  }
243
- if (workerAliases.size)
244
- recentEvents = recentEvents.map((event) => {
245
- if (!event.targetWorker)
246
- return event;
247
- const resolvedJobId = eventJobId(event);
248
- if (!resolvedJobId)
249
- return event;
250
- const key = `${resolvedJobId}:${event.targetWorker}`;
251
- const targetWorker = workerAliases.get(key);
252
- return targetWorker ? { ...event, targetWorker } : event;
253
- });
254
- // Claude's fan file can arrive just before its delegation hook. Publishing
255
- // that unmatched native id for a single poll makes the office animate a
256
- // second person. Hold it briefly for correlation; canonical/event-backed
257
- // workers remain immediate, and hookless workers appear after the grace.
258
- const correlatedWorkerIds = new Set(recentEvents.filter((event) => event.source === "custom" && event.phase === "delegating").map((event) => event.targetWorker).filter((id) => Boolean(id)));
259
- workers = workers.filter((worker) => nativeEvidence.has(`${worker.jobId}:${worker.id}`) || correlatedWorkerIds.has(worker.id) || now - worker.startedAt >= workerCorrelationGraceMs);
194
+ recentEvents = recentEvents.map(resolved.rewrite);
260
195
  const derived = deriveStatus(session, latest.job, recentEvents, now);
261
- const workerIds = new Set(workers.map((worker) => worker.id));
262
- for (const event of recentEvents) {
263
- if (!event.targetWorker || workerIds.has(event.targetWorker))
264
- continue;
265
- const related = recentEvents.filter((candidate) => candidate.targetWorker === event.targetWorker);
266
- const start = related.find((candidate) => candidate.phase === "delegating" || candidate.phase?.toLowerCase().includes("subagentstart") || candidate.hookEventName?.toLowerCase() === "subagentstart");
267
- if (!start)
268
- continue;
269
- // A friendly delegation target is an alias, not a person. Hold every
270
- // unmatched identity through the correlation window; a native Agent id
271
- // normally arrives within milliseconds and becomes the sole actor.
272
- if (now - start.ts < workerCorrelationGraceMs)
273
- continue;
274
- const done = [...related].reverse().find((candidate) => candidate.phase === "worker_reported" || candidate.phase?.toLowerCase().includes("subagentstop") || candidate.hookEventName?.toLowerCase() === "subagentstop");
275
- const resolvedJobId = eventJobId(event);
276
- if (!resolvedJobId)
277
- continue;
278
- workers.push({ id: event.targetWorker, label: event.targetWorker, kind: "subagent", startedAt: start.ts, doneAt: done?.ts, updatedAt: related.at(-1)?.ts, jobId: resolvedJobId, jobState: done ? "done" : latest.job.state });
279
- workerIds.add(event.targetWorker);
280
- }
281
196
  const derivedMessages = group.flatMap(({ jobId, job }) => {
282
197
  const createdAt = dateMs(job.createdAt);
283
198
  const updatedAt = dateMs(job.updatedAt) || createdAt;
@@ -319,7 +234,9 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
319
234
  lifecycleStatus: "active",
320
235
  mode: "orchestrated",
321
236
  };
322
- const missionPlan = planStore?.get(missionId);
237
+ // Derived at read time rather than written back: the events are the source
238
+ // of truth, so this always reflects the latest ones without extra writes.
239
+ const missionPlan = deriveChangedFiles(planStore?.get(missionId), workers, recentEvents);
323
240
  const inactiveForMs = Math.max(0, now - updatedAt);
324
241
  let activityStatus = derived.status;
325
242
  if (mission.lifecycleStatus === "completed" || mission.lifecycleStatus === "archived") {
@@ -338,20 +255,34 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
338
255
  // In that case the runtime is idle, not mission-complete.
339
256
  if (activityStatus === "done")
340
257
  activityStatus = "idle";
341
- 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")));
342
- 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.
343
270
  activityStatus = "stalled";
344
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
+ }
345
277
  }
346
278
  const name = missions?.nameFor(missionId) ?? oldest.job.name ?? latest.job.name ?? missionId;
347
- const turnCompleted = latest.job.state === "done";
348
279
  const pendingDecisions = pendingDecisionsFor({ missionId, name, jobId: latest.jobId, mission, lifecycleStatus: mission.lifecycleStatus, turnCompleted, recentEvents, updatedAt });
349
280
  // An unanswered decision is, by definition, waiting on the user. Do not let the
350
281
  // runtime's quiet session decay it to idle/stalled while the question stands.
351
282
  if (mission.lifecycleStatus === "active" && pendingDecisions.length && (activityStatus === "idle" || activityStatus === "stalled"))
352
283
  activityStatus = "waiting_on_you";
353
284
  const runStartedAt = dateMs(latest.job.createdAt) || createdAt;
354
- 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 });
355
286
  orchestrators.push({
356
287
  missionId,
357
288
  threadId: missionId,