@shanesaravia/hive 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/README.md +70 -10
- package/node_modules/@hive/shared/dist/directStudio.d.ts +7 -0
- package/node_modules/@hive/shared/dist/directStudio.js +17 -0
- package/node_modules/@hive/shared/dist/index.d.ts +1 -0
- package/node_modules/@hive/shared/dist/index.js +1 -0
- package/node_modules/@hive/shared/dist/status.d.ts +1 -1
- package/node_modules/@hive/shared/dist/status.js +19 -7
- package/node_modules/@hive/shared/dist/types.d.ts +5 -0
- package/node_modules/@hive/shared/package.json +3 -0
- package/package.json +1 -1
- package/packages/server/dist/api/rest.js +229 -14
- package/packages/server/dist/control/codexRuntime.js +24 -4
- package/packages/server/dist/hooks/hookIngest.js +44 -1
- package/packages/server/dist/missions/missionsStore.js +12 -0
- package/packages/server/dist/plans/plansStore.js +35 -4
- package/packages/server/dist/roster/rosterBuilder.js +212 -39
- package/packages/server/dist/skills/skillDiscovery.js +10 -8
- package/packages/server/dist/skills/skillInvocation.js +11 -0
- package/packages/server/dist/worktrees/worktreeReclaim.js +156 -0
- package/packages/web/dist/assets/index-Bzle5Xla.css +2 -0
- package/packages/web/dist/assets/index-C6AY0vYC.js +11 -0
- package/packages/web/dist/index.html +2 -2
- package/templates/agents/hive-orchestrator.md +6 -0
- package/packages/web/dist/assets/index-CrKMFCkZ.js +0 -11
- package/packages/web/dist/assets/index-gEGU_lr3.css +0 -2
|
@@ -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();
|
|
@@ -51,6 +52,51 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
51
52
|
group.push({ jobId, job });
|
|
52
53
|
groups.set(missionId, group);
|
|
53
54
|
}
|
|
55
|
+
// Per-mission pending decisions. Used both for the global inbox and, inside the
|
|
56
|
+
// mission loop, to keep a mission reading as waiting_on_you for as long as a
|
|
57
|
+
// decision is unanswered — even after the provider session record has gone
|
|
58
|
+
// quiet — so the office lighting, desk, status pill, and "?" badge agree.
|
|
59
|
+
const pendingDecisionsFor = (node) => {
|
|
60
|
+
if (node.lifecycleStatus === "completed" || node.lifecycleStatus === "archived")
|
|
61
|
+
return [];
|
|
62
|
+
const resolved = new Set(node.recentEvents.filter((event) => event.phase === "decision_resolved" && event.decisionId).map((event) => event.decisionId));
|
|
63
|
+
const missionContext = { missionObjective: node.mission.objective || undefined, repository: node.mission.repository };
|
|
64
|
+
// Only the current, mid-turn blocker belongs in the decision inbox. An
|
|
65
|
+
// unresolved event from an older/completed turn is history: the mission
|
|
66
|
+
// may remain open for optional follow-up, but it is not waiting on the
|
|
67
|
+
// user. Requiring the blocker to be the latest observed event also clears
|
|
68
|
+
// it as soon as provider activity resumes.
|
|
69
|
+
const latestEvent = node.recentEvents.at(-1);
|
|
70
|
+
const fromEvents = !node.turnCompleted && latestEvent?.phase === "blocked_on_user"
|
|
71
|
+
&& latestEvent.decisionId && !resolved.has(latestEvent.decisionId)
|
|
72
|
+
&& (!latestEvent.jobId || latestEvent.jobId === node.jobId)
|
|
73
|
+
? [{
|
|
74
|
+
id: latestEvent.decisionId, missionId: node.missionId, missionName: node.name,
|
|
75
|
+
kind: latestEvent.decisionKind ?? "question", question: latestEvent.detail, context: latestEvent.context,
|
|
76
|
+
choices: latestEvent.choices ?? [], recommendation: latestEvent.recommendation, impact: latestEvent.impact, createdAt: latestEvent.ts, ...missionContext,
|
|
77
|
+
}]
|
|
78
|
+
: [];
|
|
79
|
+
// A permission prompt parks the session mid-turn (tempo blocked, the ask
|
|
80
|
+
// in needs) with no TTY to answer it — surface it as an answerable
|
|
81
|
+
// decision. It self-clears once the job is no longer parked.
|
|
82
|
+
const latestJob = jobs.get(node.jobId);
|
|
83
|
+
const parked = !node.turnCompleted && latestJob?.tempo === "blocked" && latestJob.needs && /^approve\s/i.test(latestJob.needs) && !resolved.has(`perm:${node.jobId}`)
|
|
84
|
+
? [{
|
|
85
|
+
id: `perm:${node.jobId}`, missionId: node.missionId, missionName: node.name,
|
|
86
|
+
kind: "permission", question: latestJob.needs, context: latestJob.detail,
|
|
87
|
+
choices: ["Approve & continue", "Deny"], impact: "The session is parked at this prompt until you answer.", createdAt: node.updatedAt, ...missionContext,
|
|
88
|
+
}]
|
|
89
|
+
: [];
|
|
90
|
+
const hasStructuredPrompt = node.recentEvents.some((event) => event.jobId === node.jobId && event.phase === "blocked_on_user" && event.decisionId?.startsWith("skill-prompt:"));
|
|
91
|
+
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}`)
|
|
92
|
+
? [{
|
|
93
|
+
id: `prompt:${node.jobId}`, missionId: node.missionId, missionName: node.name,
|
|
94
|
+
kind: "question", question: latestJob.needs, context: latestJob.detail,
|
|
95
|
+
choices: latestJob.promptChoices ?? [], impact: "The provider is waiting for this answer before the skill can continue.", createdAt: node.updatedAt, ...missionContext,
|
|
96
|
+
}]
|
|
97
|
+
: [];
|
|
98
|
+
return [...fromEvents, ...parked, ...providerPrompt];
|
|
99
|
+
};
|
|
54
100
|
for (const [missionId, group] of groups) {
|
|
55
101
|
group.sort((a, b) => dateMs(a.job.createdAt) - dateMs(b.job.createdAt));
|
|
56
102
|
const latest = group[group.length - 1];
|
|
@@ -73,28 +119,163 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
73
119
|
eventIds.add(jobId);
|
|
74
120
|
if (session)
|
|
75
121
|
eventIds.add(session.sessionId);
|
|
76
|
-
const
|
|
77
|
-
.flatMap((id) => events.recentFor(id))
|
|
122
|
+
const allEvents = [...new Map([...eventIds]
|
|
123
|
+
.flatMap((id) => events.recentFor(id, 200))
|
|
78
124
|
.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
|
-
|
|
81
|
-
|
|
82
|
-
const workers = group.flatMap(({ jobId, job }) => (job.fan ?? []).map((worker) => ({
|
|
125
|
+
.sort((a, b) => a.ts - b.ts);
|
|
126
|
+
let recentEvents = allEvents.slice(-100);
|
|
127
|
+
let workers = group.flatMap(({ jobId, job }) => (job.fan ?? []).filter((worker) => worker.kind === "agent" || worker.kind === "subagent").map((worker) => ({
|
|
83
128
|
...worker,
|
|
84
129
|
jobId,
|
|
85
130
|
jobState: job.state,
|
|
86
131
|
jobUpdatedAt: dateMs(job.updatedAt) || undefined,
|
|
87
132
|
})));
|
|
133
|
+
const jobIdBySession = new Map(group.flatMap(({ jobId, job }) => {
|
|
134
|
+
const linkedSession = sessionsByJob.get(jobId);
|
|
135
|
+
return [...(job.sessionId ? [[job.sessionId, jobId]] : []), ...(linkedSession ? [[linkedSession.sessionId, jobId]] : [])];
|
|
136
|
+
}));
|
|
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
|
+
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);
|
|
202
|
+
}
|
|
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;
|
|
214
|
+
}
|
|
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
|
+
}
|
|
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);
|
|
260
|
+
const derived = deriveStatus(session, latest.job, recentEvents, now);
|
|
88
261
|
const workerIds = new Set(workers.map((worker) => worker.id));
|
|
89
262
|
for (const event of recentEvents) {
|
|
90
263
|
if (!event.targetWorker || workerIds.has(event.targetWorker))
|
|
91
264
|
continue;
|
|
92
265
|
const related = recentEvents.filter((candidate) => candidate.targetWorker === event.targetWorker);
|
|
93
|
-
const start = related.find((candidate) => candidate.phase === "delegating" || candidate.phase?.toLowerCase().includes("subagentstart"));
|
|
266
|
+
const start = related.find((candidate) => candidate.phase === "delegating" || candidate.phase?.toLowerCase().includes("subagentstart") || candidate.hookEventName?.toLowerCase() === "subagentstart");
|
|
94
267
|
if (!start)
|
|
95
268
|
continue;
|
|
96
|
-
|
|
97
|
-
|
|
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 });
|
|
98
279
|
workerIds.add(event.targetWorker);
|
|
99
280
|
}
|
|
100
281
|
const derivedMessages = group.flatMap(({ jobId, job }) => {
|
|
@@ -141,7 +322,15 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
141
322
|
const missionPlan = planStore?.get(missionId);
|
|
142
323
|
const inactiveForMs = Math.max(0, now - updatedAt);
|
|
143
324
|
let activityStatus = derived.status;
|
|
144
|
-
if (mission.lifecycleStatus
|
|
325
|
+
if (mission.lifecycleStatus === "completed" || mission.lifecycleStatus === "archived") {
|
|
326
|
+
// Durable terminal lifecycle is authoritative over a stale provider ask,
|
|
327
|
+
// parked permission, or process record left behind by the final turn.
|
|
328
|
+
activityStatus = "done";
|
|
329
|
+
}
|
|
330
|
+
else if (mission.lifecycleStatus === "failed") {
|
|
331
|
+
activityStatus = "error";
|
|
332
|
+
}
|
|
333
|
+
else if (mission.lifecycleStatus !== "active" && !session) {
|
|
145
334
|
activityStatus = "offline";
|
|
146
335
|
}
|
|
147
336
|
else if (mission.lifecycleStatus === "active") {
|
|
@@ -154,8 +343,15 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
154
343
|
activityStatus = "stalled";
|
|
155
344
|
}
|
|
156
345
|
}
|
|
346
|
+
const name = missions?.nameFor(missionId) ?? oldest.job.name ?? latest.job.name ?? missionId;
|
|
347
|
+
const turnCompleted = latest.job.state === "done";
|
|
348
|
+
const pendingDecisions = pendingDecisionsFor({ missionId, name, jobId: latest.jobId, mission, lifecycleStatus: mission.lifecycleStatus, turnCompleted, recentEvents, updatedAt });
|
|
349
|
+
// An unanswered decision is, by definition, waiting on the user. Do not let the
|
|
350
|
+
// runtime's quiet session decay it to idle/stalled while the question stands.
|
|
351
|
+
if (mission.lifecycleStatus === "active" && pendingDecisions.length && (activityStatus === "idle" || activityStatus === "stalled"))
|
|
352
|
+
activityStatus = "waiting_on_you";
|
|
157
353
|
const runStartedAt = dateMs(latest.job.createdAt) || createdAt;
|
|
158
|
-
const alerts = deriveAlerts({ lifecycle: mission.lifecycleStatus, activity: activityStatus, stale: derived.stale, turnCompleted
|
|
354
|
+
const alerts = deriveAlerts({ lifecycle: mission.lifecycleStatus, activity: activityStatus, stale: derived.stale, turnCompleted, workers, events: recentEvents, now, turnTokens: latest.job.tokens ?? 0, runStartedAt });
|
|
159
355
|
orchestrators.push({
|
|
160
356
|
missionId,
|
|
161
357
|
threadId: missionId,
|
|
@@ -168,16 +364,13 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
168
364
|
latest.job.resumeSessionId ??
|
|
169
365
|
latest.jobId,
|
|
170
366
|
pid: session?.pid ?? -1,
|
|
171
|
-
name
|
|
172
|
-
oldest.job.name ??
|
|
173
|
-
latest.job.name ??
|
|
174
|
-
missionId,
|
|
367
|
+
name,
|
|
175
368
|
status: activityStatus,
|
|
176
369
|
lifecycleStatus: mission.lifecycleStatus,
|
|
177
370
|
activityStatus,
|
|
178
|
-
turnCompleted
|
|
371
|
+
turnCompleted,
|
|
179
372
|
inactiveForMs,
|
|
180
|
-
waitingFor: session?.waitingFor ?? latest.job.needs,
|
|
373
|
+
waitingFor: mission.lifecycleStatus === "completed" || mission.lifecycleStatus === "archived" ? undefined : session?.waitingFor ?? latest.job.needs,
|
|
181
374
|
worktreePath: latest.job.worktreePath ?? oldest.job.worktreePath,
|
|
182
375
|
worktreeBranch: latest.job.worktreeBranch ?? oldest.job.worktreeBranch,
|
|
183
376
|
tokens: group.reduce((total, { job }) => total + (job.tokens ?? 0), 0),
|
|
@@ -203,7 +396,7 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
203
396
|
continue;
|
|
204
397
|
const jobEntry = session.jobId ? jobs.get(session.jobId) : undefined;
|
|
205
398
|
const recentEvents = events.recentFor(session.sessionId);
|
|
206
|
-
const { status } = deriveStatus(session, jobEntry, recentEvents);
|
|
399
|
+
const { status } = deriveStatus(session, jobEntry, recentEvents, now);
|
|
207
400
|
other.push({
|
|
208
401
|
jobId: session.jobId,
|
|
209
402
|
sessionId: session.sessionId,
|
|
@@ -218,26 +411,6 @@ export function buildFleetSnapshot(sessions, jobs, events, missions, messageStor
|
|
|
218
411
|
});
|
|
219
412
|
}
|
|
220
413
|
orchestrators.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
221
|
-
const decisions = orchestrators.flatMap((node) =>
|
|
222
|
-
const resolved = new Set(node.recentEvents.filter((event) => event.phase === "decision_resolved" && event.decisionId).map((event) => event.decisionId));
|
|
223
|
-
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
|
-
}));
|
|
229
|
-
// A permission prompt parks the session mid-turn (tempo blocked, the ask
|
|
230
|
-
// in needs) with no TTY to answer it — surface it as an answerable
|
|
231
|
-
// decision. It self-clears once the job is no longer parked.
|
|
232
|
-
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}`)
|
|
234
|
-
? [{
|
|
235
|
-
id: `perm:${node.jobId}`, missionId: node.missionId, missionName: node.name,
|
|
236
|
-
kind: "permission", question: latestJob.needs, context: latestJob.detail,
|
|
237
|
-
choices: ["Approve & continue", "Deny"], impact: "The session is parked at this prompt until you answer.", createdAt: node.updatedAt, ...missionContext,
|
|
238
|
-
}]
|
|
239
|
-
: [];
|
|
240
|
-
return [...fromEvents, ...parked];
|
|
241
|
-
}).sort((a, b) => b.createdAt - a.createdAt);
|
|
414
|
+
const decisions = orchestrators.flatMap((node) => pendingDecisionsFor(node)).sort((a, b) => b.createdAt - a.createdAt);
|
|
242
415
|
return { orchestrators, decisions, other, generatedAt: Date.now() };
|
|
243
416
|
}
|
|
@@ -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
|
|
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(
|
|
59
|
-
...collect(path.join(
|
|
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
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Hive launches missions through the provider CLI's native `--worktree`
|
|
6
|
+
* primitive (see control/launcher.ts) but nothing ever reclaimed the result,
|
|
7
|
+
* so every templated mission leaked a checked-out worktree and a branch.
|
|
8
|
+
*
|
|
9
|
+
* Reclamation is deliberately scoped to worktrees Hive itself caused to
|
|
10
|
+
* exist: the CLI creates them under `<repo>/.claude/worktrees/<name>` on a
|
|
11
|
+
* `worktree-<name>` branch. Anything outside that layout is left alone, so a
|
|
12
|
+
* stale or wrong `worktreePath` on a job record can never delete a real
|
|
13
|
+
* checkout.
|
|
14
|
+
*/
|
|
15
|
+
/** Path segment the provider CLI uses for its managed worktrees. */
|
|
16
|
+
const MANAGED_SEGMENT = path.join(".claude", "worktrees");
|
|
17
|
+
function git(cwd, args) {
|
|
18
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* git may answer --git-common-dir with either an absolute or a
|
|
22
|
+
* repository-relative path depending on where it runs, and resolving a
|
|
23
|
+
* relative answer against the wrong base silently yields a bogus path.
|
|
24
|
+
* --path-format is authoritative where available (git 2.31+).
|
|
25
|
+
*/
|
|
26
|
+
function commonGitDir(cwd) {
|
|
27
|
+
try {
|
|
28
|
+
return git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return path.resolve(cwd, git(cwd, ["rev-parse", "--git-common-dir"]));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A managed worktree lives under `.claude/worktrees/` AND reports a git common
|
|
36
|
+
* directory different from its own git dir. Both checks matter: the path shape
|
|
37
|
+
* alone would trust unverified job metadata, and the git check alone would
|
|
38
|
+
* happily remove a worktree the user created by hand somewhere else.
|
|
39
|
+
*/
|
|
40
|
+
export function isManagedWorktree(worktreePath) {
|
|
41
|
+
if (!worktreePath.includes(MANAGED_SEGMENT))
|
|
42
|
+
return false;
|
|
43
|
+
let gitEntry;
|
|
44
|
+
try {
|
|
45
|
+
gitEntry = fs.statSync(path.join(worktreePath, ".git"));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
// A linked worktree records its git directory in a `.git` FILE; a main
|
|
51
|
+
// checkout has a `.git` DIRECTORY. That distinction is what stops a real
|
|
52
|
+
// repository from ever being reclaimed, and unlike comparing
|
|
53
|
+
// --git-dir against --git-common-dir it cannot be fooled by git mixing
|
|
54
|
+
// absolute and relative path output for a nested directory.
|
|
55
|
+
if (!gitEntry.isFile())
|
|
56
|
+
return false;
|
|
57
|
+
try {
|
|
58
|
+
return git(worktreePath, ["rev-parse", "--is-inside-work-tree"]) === "true";
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Uncommitted tracked or untracked changes in the worktree. */
|
|
65
|
+
export function isWorktreeDirty(worktreePath) {
|
|
66
|
+
try {
|
|
67
|
+
return git(worktreePath, ["status", "--porcelain"]).length > 0;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Unreadable means unverifiable, and unverifiable must not be discarded.
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* `completed` is reversible — a follow-up message implicitly reopens a
|
|
76
|
+
* completed mission (see rest.ts), and "Clear completed desks" completes many
|
|
77
|
+
* missions at once — so uncommitted work survives it. `deleted` and `archived`
|
|
78
|
+
* are explicit, per-mission, terminal gestures and reclaim unconditionally.
|
|
79
|
+
*/
|
|
80
|
+
function discardsUncommittedWork(trigger) {
|
|
81
|
+
return trigger !== "completed";
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Removes a mission's worktree and its branch. Safe to call for any mission:
|
|
85
|
+
* missions without a worktree, and worktrees already gone, report "absent".
|
|
86
|
+
*/
|
|
87
|
+
export function reclaimWorktree(target, trigger) {
|
|
88
|
+
const worktreePath = target.path;
|
|
89
|
+
if (!worktreePath)
|
|
90
|
+
return { status: "absent" };
|
|
91
|
+
if (!fs.existsSync(worktreePath))
|
|
92
|
+
return { status: "absent", path: worktreePath, branch: target.branch };
|
|
93
|
+
if (!isManagedWorktree(worktreePath)) {
|
|
94
|
+
return { status: "unmanaged", path: worktreePath, branch: target.branch, detail: "not a Hive-managed worktree" };
|
|
95
|
+
}
|
|
96
|
+
if (!discardsUncommittedWork(trigger) && isWorktreeDirty(worktreePath)) {
|
|
97
|
+
return { status: "kept_dirty", path: worktreePath, branch: target.branch, detail: "uncommitted changes preserved" };
|
|
98
|
+
}
|
|
99
|
+
// The main checkout owns the worktree administrative data, so removal and
|
|
100
|
+
// branch deletion must run from the common repository, not from inside the
|
|
101
|
+
// directory being deleted.
|
|
102
|
+
let repositoryRoot;
|
|
103
|
+
try {
|
|
104
|
+
repositoryRoot = path.dirname(commonGitDir(worktreePath));
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return { status: "failed", path: worktreePath, branch: target.branch, detail: error.message };
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
// Locked worktrees are Hive's own (the CLI locks some during launch);
|
|
111
|
+
// unlock is a no-op when the worktree was never locked.
|
|
112
|
+
try {
|
|
113
|
+
git(repositoryRoot, ["worktree", "unlock", worktreePath]);
|
|
114
|
+
}
|
|
115
|
+
catch { /* not locked */ }
|
|
116
|
+
git(repositoryRoot, ["worktree", "remove", "--force", worktreePath]);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
return { status: "failed", path: worktreePath, branch: target.branch, detail: error.message };
|
|
120
|
+
}
|
|
121
|
+
let detail;
|
|
122
|
+
if (target.branch) {
|
|
123
|
+
const branch = target.branch.replace(/^refs\/heads\//, "");
|
|
124
|
+
try {
|
|
125
|
+
// -D rather than -d: the branch was just verified to hold no
|
|
126
|
+
// uncommitted work, and an unmerged experiment branch is exactly what
|
|
127
|
+
// this reclaim is meant to collect. The commits stay in the reflog.
|
|
128
|
+
git(repositoryRoot, ["branch", "-D", branch]);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
detail = `worktree removed; branch ${branch} retained`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
git(repositoryRoot, ["worktree", "prune"]);
|
|
136
|
+
}
|
|
137
|
+
catch { /* best effort */ }
|
|
138
|
+
return { status: "removed", path: worktreePath, branch: target.branch, detail };
|
|
139
|
+
}
|
|
140
|
+
/** Human-readable line for the mission activity timeline. */
|
|
141
|
+
export function reclaimEventDetail(result, trigger) {
|
|
142
|
+
const name = result.path ? path.basename(result.path) : "worktree";
|
|
143
|
+
if (result.status === "removed") {
|
|
144
|
+
return `Worktree ${name} reclaimed after mission ${trigger}${result.detail ? ` · ${result.detail}` : ""}`;
|
|
145
|
+
}
|
|
146
|
+
if (result.status === "kept_dirty") {
|
|
147
|
+
return `Worktree ${name} kept: uncommitted changes remain after mission ${trigger}`;
|
|
148
|
+
}
|
|
149
|
+
if (result.status === "failed") {
|
|
150
|
+
return `Worktree ${name} could not be reclaimed: ${result.detail ?? "git error"}`;
|
|
151
|
+
}
|
|
152
|
+
if (result.status === "unmanaged") {
|
|
153
|
+
return `Worktree ${name} left in place: not a Hive-managed worktree`;
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|