@shanesaravia/hive 0.1.0 → 0.2.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.
@@ -19,6 +19,7 @@ function resultText(job) {
19
19
  }
20
20
  /** Joins Claude's short-lived jobs into durable, Hive-managed missions. */
21
21
  export function buildFleetSnapshot(sessions, jobs, events, missions, messageStore, planStore, now = Date.now()) {
22
+ const workerCorrelationGraceMs = 8_000;
22
23
  const orchestrators = [];
23
24
  const other = [];
24
25
  const sessionsByJob = new Map();
@@ -73,28 +74,163 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
73
74
  eventIds.add(jobId);
74
75
  if (session)
75
76
  eventIds.add(session.sessionId);
76
- const recentEvents = [...new Map([...eventIds]
77
- .flatMap((id) => events.recentFor(id))
77
+ const allEvents = [...new Map([...eventIds]
78
+ .flatMap((id) => events.recentFor(id, 200))
78
79
  .map((event) => [`${event.sessionId}:${event.jobId ?? ""}:${event.ts}:${event.phase}:${event.decisionId ?? ""}:${event.targetWorker ?? ""}:${event.detail}`, event])).values()]
79
- .sort((a, b) => a.ts - b.ts)
80
- .slice(-100);
81
- const derived = deriveStatus(session, latest.job, recentEvents);
82
- const workers = group.flatMap(({ jobId, job }) => (job.fan ?? []).map((worker) => ({
80
+ .sort((a, b) => a.ts - b.ts);
81
+ let recentEvents = allEvents.slice(-100);
82
+ let workers = group.flatMap(({ jobId, job }) => (job.fan ?? []).filter((worker) => worker.kind === "agent" || worker.kind === "subagent").map((worker) => ({
83
83
  ...worker,
84
84
  jobId,
85
85
  jobState: job.state,
86
86
  jobUpdatedAt: dateMs(job.updatedAt) || undefined,
87
87
  })));
88
+ const jobIdBySession = new Map(group.flatMap(({ jobId, job }) => {
89
+ const linkedSession = sessionsByJob.get(jobId);
90
+ return [...(job.sessionId ? [[job.sessionId, jobId]] : []), ...(linkedSession ? [[linkedSession.sessionId, jobId]] : [])];
91
+ }));
92
+ const jobStateById = new Map(group.map(({ jobId, job }) => [jobId, job.state]));
93
+ const parentTurnFinished = (jobId) => ["done", "completed", "failed", "error", "cancelled", "canceled", "stopped"].includes((jobStateById.get(jobId) ?? "").toLowerCase());
94
+ const eventJobId = (event) => event.jobId ?? jobIdBySession.get(event.sessionId);
95
+ // A job's fan array is only a current view and can drop an earlier worker
96
+ // as soon as a later worker starts. Rebuild the durable native roster from
97
+ // hook history, keyed by Claude's real agent id, and merge fan metadata.
98
+ const nativeEvidence = new Map();
99
+ for (const event of allEvents) {
100
+ const resolvedJobId = eventJobId(event);
101
+ const raw = event.rawPayload && typeof event.rawPayload === "object" ? event.rawPayload : {};
102
+ const response = raw.tool_response && typeof raw.tool_response === "object" ? raw.tool_response : {};
103
+ const input = raw.tool_input && typeof raw.tool_input === "object" ? raw.tool_input : {};
104
+ const launchId = typeof response.agentId === "string" ? response.agentId : typeof response.agent_id === "string" ? response.agent_id : undefined;
105
+ const nativeId = launchId ?? event.targetWorker;
106
+ if (!resolvedJobId || !nativeId || nativeId === "hive-orchestrator")
107
+ continue;
108
+ const isStart = event.hookEventName === "SubagentStart" || (event.hookEventName === "PostToolUse" && event.toolName === "Agent" && Boolean(launchId));
109
+ const isStop = event.hookEventName === "SubagentStop";
110
+ if (!isStart && !isStop)
111
+ continue;
112
+ const label = typeof response.description === "string" ? response.description : typeof input.description === "string" ? input.description : nativeId;
113
+ const key = `${resolvedJobId}:${nativeId}`;
114
+ const prior = nativeEvidence.get(key);
115
+ // A stop without any observed launch is often a nested/background task
116
+ // notification. It is evidence, but not enough to invent a person.
117
+ if (isStop && !prior)
118
+ continue;
119
+ nativeEvidence.set(key, {
120
+ id: nativeId,
121
+ jobId: resolvedJobId,
122
+ label: prior?.label !== prior?.id ? prior.label : label,
123
+ startedAt: Math.min(prior?.startedAt ?? event.ts, event.ts),
124
+ doneAt: isStop ? event.ts : isStart ? undefined : prior?.doneAt,
125
+ updatedAt: event.ts,
126
+ running: isStart,
127
+ });
128
+ }
129
+ const workerByNativeKey = new Map(workers.map((worker) => [`${worker.jobId}:${worker.id}`, worker]));
130
+ for (const native of nativeEvidence.values()) {
131
+ const key = `${native.jobId}:${native.id}`;
132
+ const fanWorker = workerByNativeKey.get(key);
133
+ const turnFinished = parentTurnFinished(native.jobId);
134
+ // SubagentStop is the worker's own terminal lifecycle boundary. The
135
+ // manager may remain idle/open for follow-up, but that must not keep a
136
+ // finished worker seated indefinitely. A later SubagentStart for the
137
+ // same native id clears native.doneAt above and resumes that person.
138
+ const workerFinished = Boolean(native.doneAt) || turnFinished;
139
+ const merged = fanWorker ? {
140
+ ...fanWorker,
141
+ label: fanWorker.label || native.label,
142
+ startedAt: Math.min(fanWorker.startedAt, native.startedAt),
143
+ doneAt: native.doneAt ?? (turnFinished ? fanWorker.doneAt : undefined),
144
+ updatedAt: Math.max(fanWorker.updatedAt ?? 0, native.updatedAt),
145
+ jobState: workerFinished ? "done" : native.running ? "working" : "idle",
146
+ } : {
147
+ id: native.id,
148
+ kind: "agent",
149
+ label: native.label,
150
+ startedAt: native.startedAt,
151
+ doneAt: native.doneAt,
152
+ updatedAt: native.updatedAt,
153
+ jobId: native.jobId,
154
+ jobState: workerFinished ? "done" : native.running ? "working" : "idle",
155
+ };
156
+ workerByNativeKey.set(key, merged);
157
+ }
158
+ workers = [...workerByNativeKey.values()];
159
+ // A resumed/follow-up job may address the same provider-native agent id.
160
+ // The mission roster is person-oriented, so expose that id once using its
161
+ // latest lifecycle state instead of creating duplicate React/desk actors
162
+ // for each short-lived parent job.
163
+ const workersByIdentity = new Map();
164
+ for (const worker of workers.sort((a, b) => a.startedAt - b.startedAt || a.id.localeCompare(b.id))) {
165
+ const prior = workersByIdentity.get(worker.id);
166
+ if (!prior) {
167
+ workersByIdentity.set(worker.id, worker);
168
+ continue;
169
+ }
170
+ const priorFreshness = prior.updatedAt ?? prior.doneAt ?? prior.startedAt;
171
+ const workerFreshness = worker.updatedAt ?? worker.doneAt ?? worker.startedAt;
172
+ const latest = workerFreshness >= priorFreshness ? worker : prior;
173
+ workersByIdentity.set(worker.id, {
174
+ ...latest,
175
+ label: latest.label || prior.label,
176
+ startedAt: Math.min(prior.startedAt, worker.startedAt),
177
+ });
178
+ }
179
+ workers = [...workersByIdentity.values()];
180
+ // Friendly/custom worker names are presentation aliases only. Native
181
+ // Claude ids remain canonical so close or repeated delegations can never
182
+ // swap React identities or manufacture an extra avatar.
183
+ const workerAliases = new Map();
184
+ const claimedNativeWorkers = new Set();
185
+ for (const event of allEvents.filter((candidate) => candidate.source === "custom" && candidate.phase === "delegating" && candidate.targetWorker)) {
186
+ const resolvedJobId = eventJobId(event);
187
+ if (!resolvedJobId)
188
+ continue;
189
+ const aliasKey = `${resolvedJobId}:${event.targetWorker}`;
190
+ const candidate = workers
191
+ .filter((worker) => worker.jobId === resolvedJobId && !claimedNativeWorkers.has(worker.id) && Math.abs(worker.startedAt - event.ts) <= 30_000)
192
+ .sort((a, b) => Math.abs(a.startedAt - event.ts) - Math.abs(b.startedAt - event.ts))[0];
193
+ workerAliases.set(aliasKey, candidate?.id ?? aliasKey);
194
+ if (!candidate)
195
+ continue;
196
+ claimedNativeWorkers.add(candidate.id);
197
+ }
198
+ if (workerAliases.size)
199
+ recentEvents = recentEvents.map((event) => {
200
+ if (!event.targetWorker)
201
+ return event;
202
+ const resolvedJobId = eventJobId(event);
203
+ if (!resolvedJobId)
204
+ return event;
205
+ const key = `${resolvedJobId}:${event.targetWorker}`;
206
+ const targetWorker = workerAliases.get(key);
207
+ return targetWorker ? { ...event, targetWorker } : event;
208
+ });
209
+ // Claude's fan file can arrive just before its delegation hook. Publishing
210
+ // that unmatched native id for a single poll makes the office animate a
211
+ // second person. Hold it briefly for correlation; canonical/event-backed
212
+ // workers remain immediate, and hookless workers appear after the grace.
213
+ const correlatedWorkerIds = new Set(recentEvents.filter((event) => event.source === "custom" && event.phase === "delegating").map((event) => event.targetWorker).filter((id) => Boolean(id)));
214
+ workers = workers.filter((worker) => nativeEvidence.has(`${worker.jobId}:${worker.id}`) || correlatedWorkerIds.has(worker.id) || now - worker.startedAt >= workerCorrelationGraceMs);
215
+ const derived = deriveStatus(session, latest.job, recentEvents, now);
88
216
  const workerIds = new Set(workers.map((worker) => worker.id));
89
217
  for (const event of recentEvents) {
90
218
  if (!event.targetWorker || workerIds.has(event.targetWorker))
91
219
  continue;
92
220
  const related = recentEvents.filter((candidate) => candidate.targetWorker === event.targetWorker);
93
- const start = related.find((candidate) => candidate.phase === "delegating" || candidate.phase?.toLowerCase().includes("subagentstart"));
221
+ const start = related.find((candidate) => candidate.phase === "delegating" || candidate.phase?.toLowerCase().includes("subagentstart") || candidate.hookEventName?.toLowerCase() === "subagentstart");
94
222
  if (!start)
95
223
  continue;
96
- const done = [...related].reverse().find((candidate) => candidate.phase === "worker_reported" || candidate.phase?.toLowerCase().includes("subagentstop"));
97
- workers.push({ id: event.targetWorker, label: event.targetWorker, kind: "subagent", startedAt: start.ts, doneAt: done?.ts, updatedAt: related.at(-1)?.ts, jobId: event.jobId ?? latest.jobId, jobState: done ? "done" : latest.job.state });
224
+ // A friendly delegation target is an alias, not a person. Hold every
225
+ // unmatched identity through the correlation window; a native Agent id
226
+ // normally arrives within milliseconds and becomes the sole actor.
227
+ if (now - start.ts < workerCorrelationGraceMs)
228
+ continue;
229
+ const done = [...related].reverse().find((candidate) => candidate.phase === "worker_reported" || candidate.phase?.toLowerCase().includes("subagentstop") || candidate.hookEventName?.toLowerCase() === "subagentstop");
230
+ const resolvedJobId = eventJobId(event);
231
+ if (!resolvedJobId)
232
+ continue;
233
+ 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 });
98
234
  workerIds.add(event.targetWorker);
99
235
  }
100
236
  const derivedMessages = group.flatMap(({ jobId, job }) => {
@@ -141,7 +277,15 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
141
277
  const missionPlan = planStore?.get(missionId);
142
278
  const inactiveForMs = Math.max(0, now - updatedAt);
143
279
  let activityStatus = derived.status;
144
- if (mission.lifecycleStatus !== "active" && !session) {
280
+ if (mission.lifecycleStatus === "completed" || mission.lifecycleStatus === "archived") {
281
+ // Durable terminal lifecycle is authoritative over a stale provider ask,
282
+ // parked permission, or process record left behind by the final turn.
283
+ activityStatus = "done";
284
+ }
285
+ else if (mission.lifecycleStatus === "failed") {
286
+ activityStatus = "error";
287
+ }
288
+ else if (mission.lifecycleStatus !== "active" && !session) {
145
289
  activityStatus = "offline";
146
290
  }
147
291
  else if (mission.lifecycleStatus === "active") {
@@ -177,7 +321,7 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
177
321
  activityStatus,
178
322
  turnCompleted: latest.job.state === "done",
179
323
  inactiveForMs,
180
- waitingFor: session?.waitingFor ?? latest.job.needs,
324
+ waitingFor: mission.lifecycleStatus === "completed" || mission.lifecycleStatus === "archived" ? undefined : session?.waitingFor ?? latest.job.needs,
181
325
  worktreePath: latest.job.worktreePath ?? oldest.job.worktreePath,
182
326
  worktreeBranch: latest.job.worktreeBranch ?? oldest.job.worktreeBranch,
183
327
  tokens: group.reduce((total, { job }) => total + (job.tokens ?? 0), 0),
@@ -203,7 +347,7 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
203
347
  continue;
204
348
  const jobEntry = session.jobId ? jobs.get(session.jobId) : undefined;
205
349
  const recentEvents = events.recentFor(session.sessionId);
206
- const { status } = deriveStatus(session, jobEntry, recentEvents);
350
+ const { status } = deriveStatus(session, jobEntry, recentEvents, now);
207
351
  other.push({
208
352
  jobId: session.jobId,
209
353
  sessionId: session.sessionId,
@@ -219,25 +363,45 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
219
363
  }
220
364
  orchestrators.sort((a, b) => b.updatedAt - a.updatedAt);
221
365
  const decisions = orchestrators.flatMap((node) => {
366
+ if (node.lifecycleStatus === "completed" || node.lifecycleStatus === "archived")
367
+ return [];
222
368
  const resolved = new Set(node.recentEvents.filter((event) => event.phase === "decision_resolved" && event.decisionId).map((event) => event.decisionId));
223
369
  const missionContext = { missionObjective: node.mission.objective || undefined, repository: node.mission.repository };
224
- const fromEvents = node.recentEvents.filter((event) => event.phase === "blocked_on_user" && event.decisionId && !resolved.has(event.decisionId)).map((event) => ({
225
- id: event.decisionId, missionId: node.missionId, missionName: node.name,
226
- kind: event.decisionKind ?? "question", question: event.detail, context: event.context,
227
- choices: event.choices ?? [], recommendation: event.recommendation, impact: event.impact, createdAt: event.ts, ...missionContext,
228
- }));
370
+ // Only the current, mid-turn blocker belongs in the decision inbox. An
371
+ // unresolved event from an older/completed turn is history: the mission
372
+ // may remain open for optional follow-up, but it is not waiting on the
373
+ // user. Requiring the blocker to be the latest observed event also clears
374
+ // it as soon as provider activity resumes.
375
+ const latestEvent = node.recentEvents.at(-1);
376
+ const fromEvents = !node.turnCompleted && latestEvent?.phase === "blocked_on_user"
377
+ && latestEvent.decisionId && !resolved.has(latestEvent.decisionId)
378
+ && (!latestEvent.jobId || latestEvent.jobId === node.jobId)
379
+ ? [{
380
+ id: latestEvent.decisionId, missionId: node.missionId, missionName: node.name,
381
+ kind: latestEvent.decisionKind ?? "question", question: latestEvent.detail, context: latestEvent.context,
382
+ choices: latestEvent.choices ?? [], recommendation: latestEvent.recommendation, impact: latestEvent.impact, createdAt: latestEvent.ts, ...missionContext,
383
+ }]
384
+ : [];
229
385
  // A permission prompt parks the session mid-turn (tempo blocked, the ask
230
386
  // in needs) with no TTY to answer it — surface it as an answerable
231
387
  // decision. It self-clears once the job is no longer parked.
232
388
  const latestJob = jobs.get(node.jobId);
233
- const parked = latestJob?.tempo === "blocked" && latestJob.needs && /^approve\s/i.test(latestJob.needs) && !resolved.has(`perm:${node.jobId}`)
389
+ const parked = !node.turnCompleted && latestJob?.tempo === "blocked" && latestJob.needs && /^approve\s/i.test(latestJob.needs) && !resolved.has(`perm:${node.jobId}`)
234
390
  ? [{
235
391
  id: `perm:${node.jobId}`, missionId: node.missionId, missionName: node.name,
236
392
  kind: "permission", question: latestJob.needs, context: latestJob.detail,
237
393
  choices: ["Approve & continue", "Deny"], impact: "The session is parked at this prompt until you answer.", createdAt: node.updatedAt, ...missionContext,
238
394
  }]
239
395
  : [];
240
- return [...fromEvents, ...parked];
396
+ const hasStructuredPrompt = node.recentEvents.some((event) => event.jobId === node.jobId && event.phase === "blocked_on_user" && event.decisionId?.startsWith("skill-prompt:"));
397
+ const providerPrompt = !node.turnCompleted && latestJob?.tempo === "blocked" && latestJob.needs && (latestJob.promptChoices?.length ?? 0) > 0 && !/^approve\s/i.test(latestJob.needs) && !/rate limit|spend limit/i.test(latestJob.needs) && !hasStructuredPrompt && !resolved.has(`prompt:${node.jobId}`)
398
+ ? [{
399
+ id: `prompt:${node.jobId}`, missionId: node.missionId, missionName: node.name,
400
+ kind: "question", question: latestJob.needs, context: latestJob.detail,
401
+ choices: latestJob.promptChoices ?? [], impact: "The provider is waiting for this answer before the skill can continue.", createdAt: node.updatedAt, ...missionContext,
402
+ }]
403
+ : [];
404
+ return [...fromEvents, ...parked, ...providerPrompt];
241
405
  }).sort((a, b) => b.createdAt - a.createdAt);
242
406
  return { orchestrators, decisions, other, generatedAt: Date.now() };
243
407
  }
@@ -50,17 +50,19 @@ function collectPluginSkills(root) {
50
50
  }
51
51
  return output;
52
52
  }
53
- export function discoverSkills(cwd) {
53
+ export function discoverSkills(cwd, provider = "claude") {
54
54
  const project = requireWorkingDirectory(cwd);
55
- const all = [
55
+ const claudeConfig = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
56
+ const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
57
+ const all = provider === "codex" ? [
58
+ ...collect(path.join(project, ".codex", "skills"), "project"),
59
+ ...collect(path.join(codexHome, "skills"), "user"),
60
+ ...collectPluginSkills(path.join(codexHome, "plugins", "cache")),
61
+ ] : [
56
62
  ...collect(path.join(project, ".claude", "skills"), "project"),
57
63
  ...collect(path.join(project, ".claude", "commands"), "project"),
58
- ...collect(path.join(os.homedir(), ".claude", "skills"), "user"),
59
- ...collect(path.join(os.homedir(), ".claude", "commands"), "user"),
60
- ...collectPluginSkills(path.join(os.homedir(), ".claude", "plugins")),
61
- ...collect(path.join(project, ".codex", "skills"), "project"),
62
- ...collect(path.join(os.homedir(), ".codex", "skills"), "user"),
63
- ...collectPluginSkills(path.join(os.homedir(), ".codex", "plugins", "cache")),
64
+ ...collect(path.join(claudeConfig, "skills"), "user"),
65
+ ...collect(path.join(claudeConfig, "commands"), "user"),
64
66
  ];
65
67
  const unique = new Map();
66
68
  for (const skill of all.reverse())
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Hive exposes one consistent `/skill` syntax in its UI. Codex invokes skills
3
+ * with `$skill`, so translate only names that were discovered for the active
4
+ * provider, machine, and working directory. Unknown slash-prefixed text is
5
+ * left alone (it may be a path or ordinary prose).
6
+ */
7
+ export function translateSkillInvocations(text, provider, availableSkillNames) {
8
+ if (provider !== "codex" || availableSkillNames.size === 0)
9
+ return text;
10
+ return text.replace(/(^|\s)\/([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/g, (match, prefix, name) => availableSkillNames.has(name) ? `${prefix}$${name}` : match);
11
+ }