@shanesaravia/hive 0.4.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.
- package/CHANGELOG.md +23 -0
- package/node_modules/@hive/shared/dist/types.d.ts +8 -0
- package/node_modules/@hive/shared/dist/types.js +8 -0
- package/package.json +1 -1
- package/packages/server/dist/api/rest.js +109 -20
- package/packages/server/dist/control/missionQuiesce.js +4 -0
- package/packages/server/dist/loops/loopCommand.js +20 -0
- package/packages/server/dist/messages/messagesStore.js +28 -4
- package/packages/server/dist/missions/missionsStore.js +1 -1
- package/packages/server/dist/missions/reopenOnWork.js +7 -0
- package/packages/server/dist/paths/pathResolver.js +112 -17
- package/packages/server/dist/plans/plansStore.js +32 -2
- package/packages/server/dist/roster/workerIdentity.js +91 -23
- package/packages/web/dist/assets/index-C1T22qkK.css +2 -0
- package/packages/web/dist/assets/index-CVc04VBT.js +18 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-DWjqiitn.js +0 -17
- package/packages/web/dist/assets/index-rd4RnLqj.css +0 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Hive will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.5.0 — 2026-09-03
|
|
6
|
+
|
|
7
|
+
Turns the composer's mention system into a full reference picker — files, directories, and other repositories alongside agents and skills — carries a pasted image as part of the message text itself, and settles a run of places where the fleet misattributed a worker, mis-scored a mission's status, or left a stale manager still holding a session.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `@agent`, `/skill`, and Hive's own `/loop` now work in every composer (Quick Ask, the New Mission modal, and the conversation composer), not only the one they first shipped in.
|
|
12
|
+
- A pasted or dropped image now becomes an inline `[Image #N]` placeholder in the message text itself — the same way Claude Code's own CLI handles a paste — rather than a separate attachment strip. Deleting the placeholder drops the image from what's sent; Backspace/Delete removes one whole placeholder rather than eating it character by character.
|
|
13
|
+
- `@` completes files and directories under a mission's own working directory too, alongside agent mentions — reachable beyond the mission's own repository with a home-relative (`~/...`) or absolute path, one segment at a time with Tab, the way a shell completes a path.
|
|
14
|
+
- A new `#` sigil completes repository names for describing work spanning more than one repository — siblings of the mission's repository and recently used ones, with the mission's own repository offered too and labeled "current repo" rather than hidden.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- A worker's identity could be claimed by the wrong manager when two orchestrators on one mission delegated a task under the same plan id or `--target` label.
|
|
19
|
+
- An enriched internal prompt could appear as a second, duplicate user message.
|
|
20
|
+
- `a`/`d` now act on the top item of an open "Needs You" queue without it needing to be selected first.
|
|
21
|
+
- A reviewed orchestrated mission now returns to acceptance once follow-up work completes, instead of staying marked reviewed against stale work.
|
|
22
|
+
- A previous Claude manager is now stopped once a resumed background job takes over, instead of leaving both able to act on one mission.
|
|
23
|
+
- Read-only environment probes with useful output are classified as neutral diagnostic checks rather than errors.
|
|
24
|
+
- Plan approval now shows only for missions actually launched with Plan First.
|
|
25
|
+
- An orchestrator's own completion evidence is reconciled against equivalent user-created gates instead of showing duplicates.
|
|
26
|
+
- Needs You acceptance is blocked while required gates remain unresolved.
|
|
27
|
+
|
|
5
28
|
## 0.4.0 — 2026-09-02
|
|
6
29
|
|
|
7
30
|
Makes the conversation trustworthy to read, gives Hive loops it can actually turn off, and settles two places where a mission misreported what it was doing.
|
|
@@ -288,6 +288,14 @@ export declare const NATIVE_COMMANDS: NativeCommand[];
|
|
|
288
288
|
* at risk of being run out-of-band by mistake.
|
|
289
289
|
*/
|
|
290
290
|
export declare const BUILTIN_SKILLS: ClaudeSkillSummary[];
|
|
291
|
+
/**
|
|
292
|
+
* The built-ins that already mean something before a mission exists, so a
|
|
293
|
+
* composer that starts one can offer them. `/loop` qualifies: the mission's
|
|
294
|
+
* opening turn is the loop's first pass and Hive keeps the schedule. `/tasks`
|
|
295
|
+
* and the native commands do not — they report on a session that is not
|
|
296
|
+
* running yet.
|
|
297
|
+
*/
|
|
298
|
+
export declare const MISSION_START_SKILLS: ClaudeSkillSummary[];
|
|
291
299
|
/** One readable transcript belonging to a mission. */
|
|
292
300
|
export interface TerminalCapability {
|
|
293
301
|
/** Interactive writes target a live channel; follow_up starts/resumes a provider turn. */
|
|
@@ -39,3 +39,11 @@ export const BUILTIN_SKILLS = [
|
|
|
39
39
|
source: "built-in",
|
|
40
40
|
},
|
|
41
41
|
];
|
|
42
|
+
/**
|
|
43
|
+
* The built-ins that already mean something before a mission exists, so a
|
|
44
|
+
* composer that starts one can offer them. `/loop` qualifies: the mission's
|
|
45
|
+
* opening turn is the loop's first pass and Hive keeps the schedule. `/tasks`
|
|
46
|
+
* and the native commands do not — they report on a session that is not
|
|
47
|
+
* running yet.
|
|
48
|
+
*/
|
|
49
|
+
export const MISSION_START_SKILLS = BUILTIN_SKILLS.filter((skill) => skill.name === "loop");
|
package/package.json
CHANGED
|
@@ -6,20 +6,20 @@ import { allPlanTasks } from "../plans/plansStore.js";
|
|
|
6
6
|
import { buildFleetSnapshot } from "../roster/rosterBuilder.js";
|
|
7
7
|
import { orchestratorTranscriptPath, readTranscript, transcriptSources, workerTranscriptPath } from "../transcripts/transcriptReader.js";
|
|
8
8
|
import { skillPromptEvents, toHiveEvent } from "../hooks/hookIngest.js";
|
|
9
|
-
import { isWorkEvent, reopensOnWork } from "../missions/reopenOnWork.js";
|
|
9
|
+
import { isWorkEvent, reopensOnWork, returnsToReviewOnStop } from "../missions/reopenOnWork.js";
|
|
10
10
|
import { startOrchestrator } from "../control/launcher.js";
|
|
11
11
|
import { matchNativeCommand, runNativeCommand } from "../control/nativeCommands.js";
|
|
12
12
|
import { sendMessage } from "../control/messaging.js";
|
|
13
|
-
import { jobsToQuiesce } from "../control/missionQuiesce.js";
|
|
13
|
+
import { jobsToQuiesce, replacedManagerAfterResume } from "../control/missionQuiesce.js";
|
|
14
14
|
import { parsePermissionAsk, permissionGrant } from "../control/permissionPark.js";
|
|
15
15
|
import { forceStopSession, stopSession } from "../control/killer.js";
|
|
16
16
|
import { discoverSkills } from "../skills/skillDiscovery.js";
|
|
17
17
|
import { discoverAgents } from "../agents/agentDiscovery.js";
|
|
18
18
|
import { AttachmentStore, isSupportedMediaType, MAX_ATTACHMENT_BYTES } from "../messages/attachmentStore.js";
|
|
19
|
-
import { formatInterval, parseLoopCommand } from "../loops/loopCommand.js";
|
|
19
|
+
import { formatInterval, parseLoopCommand, parseMissionStartLoop } from "../loops/loopCommand.js";
|
|
20
20
|
import { activeMonitors } from "../loops/monitors.js";
|
|
21
21
|
import { translateSkillInvocations } from "../skills/skillInvocation.js";
|
|
22
|
-
import { detectRepositoryMentions, foreignRepositoryForPath, inspectWorkingDirectory, recentRepositories, requireWorkingDirectory, suggestDirectories } from "../paths/pathResolver.js";
|
|
22
|
+
import { detectRepositoryMentions, foreignRepositoryForPath, inspectWorkingDirectory, recentRepositories, requireWorkingDirectory, suggestDirectories, suggestPathEntries, suggestRepositories } from "../paths/pathResolver.js";
|
|
23
23
|
import { missionReport, reportMarkdown } from "../reports/missionReport.js";
|
|
24
24
|
import { createTemplate, deleteTemplate, discoverTemplates, discoverTemplatesDetailed, updateTemplate } from "../templates/templateDiscovery.js";
|
|
25
25
|
import { publishGitHubReport } from "../reports/githubPublisher.js";
|
|
@@ -113,13 +113,16 @@ export function planRevisionSummary(previous, next) {
|
|
|
113
113
|
* Named paths rather than an opaque "see attachment": the agent has to be able
|
|
114
114
|
* to open them, and an absolute path is the one instruction that works whether
|
|
115
115
|
* the turn runs in the repository, a worktree, or somewhere else entirely.
|
|
116
|
+
* Each attachment's name is the same "[Image #N]" token the user sees inline
|
|
117
|
+
* in their own message text, so this list is a direct lookup from what they
|
|
118
|
+
* already typed to the file the agent needs to read.
|
|
116
119
|
*/
|
|
117
120
|
export function promptWithAttachments(text, attached) {
|
|
118
121
|
if (!attached.length)
|
|
119
122
|
return text;
|
|
120
|
-
const list = attached.map((item) => `- ${item.path}`).join("\n");
|
|
123
|
+
const list = attached.map((item) => `- [${item.name}]: ${item.path}`).join("\n");
|
|
121
124
|
const preamble = attached.length === 1 ? "The user attached an image" : `The user attached ${attached.length} images`;
|
|
122
|
-
return `${text ? `${text}\n\n` : ""}${preamble}. Read ${attached.length === 1 ? "it" : "them"} before replying:\n${list}`;
|
|
125
|
+
return `${text ? `${text}\n\n` : ""}${preamble}, referenced inline in the message above. Read ${attached.length === 1 ? "it" : "them"} before replying:\n${list}`;
|
|
123
126
|
}
|
|
124
127
|
/** Returns the mission send path, so the loop scheduler drives turns exactly as a person's message does. */
|
|
125
128
|
export function registerRest(app, deps) {
|
|
@@ -220,6 +223,7 @@ export function registerRest(app, deps) {
|
|
|
220
223
|
missionId,
|
|
221
224
|
targetJobId,
|
|
222
225
|
});
|
|
226
|
+
const stopped = [];
|
|
223
227
|
for (const jobId of stopping) {
|
|
224
228
|
const job = jobsWatcher.getAll().get(jobId);
|
|
225
229
|
const sessionId = job?.sessionId ?? job?.resumeSessionId ?? jobId;
|
|
@@ -229,19 +233,38 @@ export function registerRest(app, deps) {
|
|
|
229
233
|
await codex.stop(jobId);
|
|
230
234
|
else
|
|
231
235
|
await stopSession(jobId);
|
|
236
|
+
stopped.push(jobId);
|
|
232
237
|
events.add({ ts: Date.now(), sessionId, jobId, source: "custom", phase: "custom", activityKind: "lifecycle", detail: superseded ? `Stopped superseded manager ${jobId.slice(0, 8)} before resuming: only one job may act on a mission.` : `Ended the parked turn on ${jobId.slice(0, 8)} so this message continues the conversation instead of branching it.` });
|
|
233
238
|
}
|
|
234
239
|
catch (error) {
|
|
235
240
|
events.add({ ts: Date.now(), sessionId, jobId, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Could not stop ${superseded ? "superseded" : "parked"} manager ${jobId.slice(0, 8)}: ${error.message}` });
|
|
236
241
|
}
|
|
237
242
|
}
|
|
243
|
+
return stopped;
|
|
244
|
+
}
|
|
245
|
+
async function resumeClaude(previousJobId, sessionId, prompt, policy, additionalRepos) {
|
|
246
|
+
const result = await sendMessage(sessionId, prompt, policy, additionalRepos);
|
|
247
|
+
const replaced = replacedManagerAfterResume(previousJobId, result.jobId);
|
|
248
|
+
if (replaced) {
|
|
249
|
+
try {
|
|
250
|
+
await stopSession(replaced);
|
|
251
|
+
events.add({ ts: Date.now(), sessionId, jobId: replaced, source: "custom", phase: "custom", activityKind: "lifecycle", detail: `Retired manager ${replaced.slice(0, 8)} after resumed job ${result.jobId.slice(0, 8)} took over` });
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
// The continuation is already live. Preserve it and make cleanup
|
|
255
|
+
// failure visible rather than turning a successful send into an error
|
|
256
|
+
// that invites the user to retry and create yet another manager.
|
|
257
|
+
events.add({ ts: Date.now(), sessionId, jobId: replaced, source: "custom", phase: "custom", activityKind: "lifecycle", outcome: "failure", detail: `Could not retire replaced manager ${replaced.slice(0, 8)}: ${error.message}` });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return result;
|
|
238
261
|
}
|
|
239
262
|
async function sendToMission(missionId, text) {
|
|
240
263
|
return withMissionQueue(missionId, async () => {
|
|
241
264
|
const target = latestMissionTarget(missionId);
|
|
242
265
|
if (!target)
|
|
243
266
|
throw new Error("mission not found");
|
|
244
|
-
await quiesceMission(missionId, target.jobId);
|
|
267
|
+
const quiesced = await quiesceMission(missionId, target.jobId);
|
|
245
268
|
const summary = missions.summaryFor(missionId);
|
|
246
269
|
const policy = summary.policy ?? policies.get();
|
|
247
270
|
const provider = summary.provider ?? "claude";
|
|
@@ -276,7 +299,7 @@ export function registerRest(app, deps) {
|
|
|
276
299
|
try {
|
|
277
300
|
result = provider === "codex"
|
|
278
301
|
? await codex.start({ task: prompt, cwd: repository, model: summary.model, mode: summary.mode, policy, resumeSessionId: target.sessionId })
|
|
279
|
-
: await
|
|
302
|
+
: await resumeClaude(quiesced.includes(target.jobId) ? undefined : target.jobId, target.sessionId, prompt, policy, additionalRepos);
|
|
280
303
|
}
|
|
281
304
|
catch (error) {
|
|
282
305
|
if (reopening)
|
|
@@ -571,6 +594,17 @@ export function registerRest(app, deps) {
|
|
|
571
594
|
events.add(promptEvent);
|
|
572
595
|
if (jobId)
|
|
573
596
|
reopenIfWorking(event, jobId);
|
|
597
|
+
if (jobId && event.hookEventName === "Stop") {
|
|
598
|
+
const missionId = missions.missionFor(jobId);
|
|
599
|
+
const mission = missions.get(missionId);
|
|
600
|
+
const pendingDecision = events.recentFor(event.sessionId).some((candidate) => candidate.jobId === jobId && candidate.phase === "blocked_on_user" && candidate.ts <= event.ts);
|
|
601
|
+
if (mission && returnsToReviewOnStop(mission, pendingDecision)) {
|
|
602
|
+
const finalMessage = typeof req.body.last_assistant_message === "string" ? req.body.last_assistant_message : undefined;
|
|
603
|
+
missions.setLifecycleStatus(missionId, "ready_for_review");
|
|
604
|
+
missions.updateContext(missionId, { compactSummary: finalMessage?.slice(0, 1_200), currentState: "Awaiting user acceptance" });
|
|
605
|
+
events.add({ ts: Date.now(), sessionId: event.sessionId, jobId, source: "custom", phase: "ready_for_review", activityKind: "lifecycle", detail: "Follow-up work finished and returned to user acceptance" });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
574
608
|
// A write landing in another known repo marks that repo as touched by
|
|
575
609
|
// the mission, so its footprint chips reflect reality, not just intent.
|
|
576
610
|
if (event.activityKind === "file_write" && event.filePath && jobId) {
|
|
@@ -671,7 +705,7 @@ export function registerRest(app, deps) {
|
|
|
671
705
|
// One emit may name several workers (`--target a,b,c`); the task moves for each.
|
|
672
706
|
const targets = targetWorker && /[,;]/.test(targetWorker) ? [...new Set(targetWorker.split(/[,;]/).map((value) => value.trim()).filter(Boolean))] : [targetWorker];
|
|
673
707
|
for (const target of targets) {
|
|
674
|
-
const canonicalWorker = workerIdentity.canonicalFor(missionId, target);
|
|
708
|
+
const canonicalWorker = workerIdentity.canonicalFor(missionId, target, effectiveJobId);
|
|
675
709
|
try {
|
|
676
710
|
plans.applyTaskEvent(missionId, { phase, targetTask, targetWorker: target, canonicalWorker, evidence: phase === "worker_reported" ? detail : undefined });
|
|
677
711
|
}
|
|
@@ -820,12 +854,32 @@ Revise the structured plan to remove that requirement and stop work that was nee
|
|
|
820
854
|
return { error: err.message };
|
|
821
855
|
}
|
|
822
856
|
});
|
|
823
|
-
app.post("/api/orchestrator/start", async (req, reply) => {
|
|
857
|
+
app.post("/api/orchestrator/start", { bodyLimit: Math.ceil(MAX_ATTACHMENT_BYTES * 1.4 * 8) }, async (req, reply) => {
|
|
824
858
|
const { task, name, cwd, templateId } = req.body;
|
|
825
859
|
if (!task) {
|
|
826
860
|
reply.code(400);
|
|
827
861
|
return { error: "task is required" };
|
|
828
862
|
}
|
|
863
|
+
const pendingAttachments = req.body.attachments ?? [];
|
|
864
|
+
for (const item of pendingAttachments) {
|
|
865
|
+
if (!isSupportedMediaType(item.mediaType)) {
|
|
866
|
+
reply.code(415);
|
|
867
|
+
return { error: `unsupported image type: ${item.mediaType || "none given"}` };
|
|
868
|
+
}
|
|
869
|
+
if (!item.data) {
|
|
870
|
+
reply.code(400);
|
|
871
|
+
return { error: "attachment data is required" };
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
// `/loop …` is Hive's wherever it is typed, not only in a conversation.
|
|
875
|
+
// Handed to the CLI as a task it would run as the agent's own loop, which
|
|
876
|
+
// Hive cannot list or stop — the failure this feature exists to prevent.
|
|
877
|
+
const start = parseMissionStartLoop(task);
|
|
878
|
+
if ("error" in start) {
|
|
879
|
+
reply.code(400);
|
|
880
|
+
return { error: start.error };
|
|
881
|
+
}
|
|
882
|
+
const missionTask = start.prompt;
|
|
829
883
|
try {
|
|
830
884
|
const template = templateId ? discoverTemplates(cwd).find((item) => item.id === templateId) : undefined;
|
|
831
885
|
if (templateId && !template) {
|
|
@@ -855,17 +909,23 @@ Revise the structured plan to remove that requirement and stop work that was nee
|
|
|
855
909
|
const provider = req.body.provider ?? "claude";
|
|
856
910
|
const model = req.body.model?.trim() || undefined;
|
|
857
911
|
const planFirst = mode === "orchestrated" && req.body.planFirst === true;
|
|
858
|
-
const additionalRepos = detectRepositoryMentions(
|
|
912
|
+
const additionalRepos = detectRepositoryMentions(missionTask, resolvedCwd);
|
|
859
913
|
const repoPrompt = additionalRepos.length ? `\n\n(Hive: you also have tool access to these repositories: ${additionalRepos.join(", ")})` : "";
|
|
860
914
|
const planFirstPrompt = planFirst ? `\n\nHive Plan First checkpoint (mandatory): Before editing files, running implementation commands, or spawning/delegating to any workers, investigate only enough to propose a concrete structured plan. Publish that plan with every task and phase still queued and approvalStatus \"proposed\". Then emit blocked_on_user with decision id \"plan-approval\", kind \"scope\", and question \"Approve the proposed plan before implementation begins?\" Stop the turn after emitting it. Do not begin implementation until Hive resumes you with explicit plan approval. If changes are requested, revise and republish the queued plan, emit the same plan-approval decision again, and stop.` : "";
|
|
861
|
-
const providerTask = translateSkillInvocations(
|
|
862
|
-
|
|
915
|
+
const providerTask = translateSkillInvocations(missionTask, provider, new Set(discoverSkills(resolvedCwd, provider).map((skill) => skill.name)));
|
|
916
|
+
// Generated ahead of `missions.create` so pasted images can be filed
|
|
917
|
+
// under the mission's id before the mission record itself exists.
|
|
918
|
+
const missionId = randomUUID();
|
|
919
|
+
const attached = pendingAttachments.map((item) => attachments.save(missionId, { name: item.name, mediaType: item.mediaType, data: Buffer.from(item.data, "base64") }));
|
|
920
|
+
const taskWithAttachments = promptWithAttachments(providerTask, attached);
|
|
921
|
+
const prompt = taskWithAttachments + templatePrompt + limits + policyPrompt + repoPrompt + planFirstPrompt;
|
|
863
922
|
const result = provider === "codex"
|
|
864
923
|
? await codex.start({ task: prompt, cwd: resolvedCwd, name, model, mode, policy })
|
|
865
924
|
: await startOrchestrator({ task: prompt, worktree, name, cwd: resolvedCwd, mode, policy, model, addDirs: additionalRepos });
|
|
866
925
|
const mission = missions.create({
|
|
926
|
+
id: missionId,
|
|
867
927
|
name,
|
|
868
|
-
objective:
|
|
928
|
+
objective: missionTask,
|
|
869
929
|
repository: result.cwd,
|
|
870
930
|
jobId: result.jobId,
|
|
871
931
|
sessionId: result.sessionId,
|
|
@@ -885,10 +945,24 @@ Revise the structured plan to remove that requirement and stop work that was nee
|
|
|
885
945
|
text: task,
|
|
886
946
|
createdAt: Date.now(),
|
|
887
947
|
jobId: result.jobId,
|
|
948
|
+
...(attached.length ? { attachments: attached } : {}),
|
|
888
949
|
});
|
|
950
|
+
// The opening turn is the loop's first pass, so the schedule starts one
|
|
951
|
+
// interval out — exactly what `loops.create` does.
|
|
952
|
+
const loop = start.loop ? loops.create({ missionId: mission.id, ...start.loop }) : undefined;
|
|
953
|
+
if (loop) {
|
|
954
|
+
messages.add({
|
|
955
|
+
id: `${result.jobId}:loop`,
|
|
956
|
+
missionId: mission.id,
|
|
957
|
+
role: "assistant",
|
|
958
|
+
text: `Looping every ${formatInterval(loop.intervalMs)}: ${loop.prompt}\n\nThis first pass is running now; the next is at ${new Date(loop.nextRunAt).toLocaleTimeString()}. Stop it with \`/loop stop ${loop.id}\`, or from the Tasks panel.`,
|
|
959
|
+
createdAt: Date.now() + 1,
|
|
960
|
+
jobId: result.jobId,
|
|
961
|
+
});
|
|
962
|
+
}
|
|
889
963
|
if (template)
|
|
890
964
|
plans.replace(mission.id, { phases: [{ id: "template", title: template.name, description: template.description, status: "queued", acceptanceCriteria: template.gates.map((gate) => gate.label), dependsOn: [], tasks: template.stages.map((stage, index) => ({ id: stage.id, title: stage.title, role: stage.role, status: "queued", weight: 1, dependsOn: stage.dependsOn ?? (index ? [template.stages[index - 1].id] : []), evidence: [] })) }], gates: template.gates.map((gate) => ({ ...gate, source: "template" })) });
|
|
891
|
-
return { ...result, missionId: mission.id };
|
|
965
|
+
return { ...result, missionId: mission.id, ...(loop ? { loopId: loop.id } : {}) };
|
|
892
966
|
}
|
|
893
967
|
catch (err) {
|
|
894
968
|
reply.code(400);
|
|
@@ -1016,6 +1090,22 @@ Revise the structured plan to remove that requirement and stop work that was nee
|
|
|
1016
1090
|
directories: req.body?.input?.trim() ? suggestDirectories(req.body.input) : recentRepositories(),
|
|
1017
1091
|
}));
|
|
1018
1092
|
app.post("/api/paths/inspect", async (req) => inspectWorkingDirectory(req.body?.input));
|
|
1093
|
+
// Live "@path" completion in a composer: files and directories under a
|
|
1094
|
+
// mission's own working directory, matching what's typed after the `@`.
|
|
1095
|
+
app.post("/api/paths/suggest-entries", async (req, reply) => {
|
|
1096
|
+
try {
|
|
1097
|
+
const cwd = requireWorkingDirectory(req.body?.cwd);
|
|
1098
|
+
return { entries: suggestPathEntries(cwd, req.body?.query ?? "") };
|
|
1099
|
+
}
|
|
1100
|
+
catch (err) {
|
|
1101
|
+
reply.code(400);
|
|
1102
|
+
return { error: err.message };
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
// Live "#repo" completion: sibling and recently used repositories matching what's typed after the `#`.
|
|
1106
|
+
app.post("/api/repositories/suggest", async (req) => ({
|
|
1107
|
+
repositories: suggestRepositories(req.body?.cwd, req.body?.query ?? ""),
|
|
1108
|
+
}));
|
|
1019
1109
|
app.post("/api/session/:pid/message", async (req, reply) => {
|
|
1020
1110
|
const pid = Number(req.params.pid);
|
|
1021
1111
|
const session = sessionsWatcher.getAll().get(pid);
|
|
@@ -1033,9 +1123,8 @@ Revise the structured plan to remove that requirement and stop work that was nee
|
|
|
1033
1123
|
// Messaging a session that belongs to a mission is the same fork as
|
|
1034
1124
|
// messaging the mission, through a second door. Only a session Hive does
|
|
1035
1125
|
// not manage as a mission is resumed without quiescing.
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
const result = await sendMessage(session.sessionId, req.body.text);
|
|
1126
|
+
const quiesced = missions.get(missionId) ? await quiesceMission(missionId, session.jobId) : [];
|
|
1127
|
+
const result = await resumeClaude(quiesced.includes(session.jobId ?? "") ? undefined : session.jobId, session.sessionId, req.body.text);
|
|
1039
1128
|
missions.linkJob(missionId, result.jobId, result.sessionId);
|
|
1040
1129
|
messages.add({
|
|
1041
1130
|
id: `${result.jobId}:user`,
|
|
@@ -1208,14 +1297,14 @@ Revise the structured plan to remove that requirement and stop work that was nee
|
|
|
1208
1297
|
try {
|
|
1209
1298
|
// Ends the parked turn, and any other manager still live on the
|
|
1210
1299
|
// mission with it — the verdict below resumes one conversation.
|
|
1211
|
-
await quiesceMission(req.params.missionId, jobId);
|
|
1300
|
+
const quiesced = await quiesceMission(req.params.missionId, jobId);
|
|
1212
1301
|
const summary = missions.summaryFor(req.params.missionId);
|
|
1213
1302
|
const policy = summary.policy ?? policies.get();
|
|
1214
1303
|
const grant = approved ? permissionGrant(job.needs) : undefined;
|
|
1215
1304
|
const text = approved
|
|
1216
1305
|
? `Your permission request (${askLabel}) is approved and now pre-allowed. Re-run it and continue where you left off.`
|
|
1217
1306
|
: `Your permission request (${askLabel}) was denied${/^deny$/i.test(answer) ? "" : ` — ${answer}`}. Continue without it or propose an alternative.`;
|
|
1218
|
-
const result = await
|
|
1307
|
+
const result = await resumeClaude(quiesced.includes(jobId) ? undefined : jobId, sessionId, text, grant ? { ...policy, allowedTools: [...policy.allowedTools, grant] } : policy);
|
|
1219
1308
|
missions.linkJob(req.params.missionId, result.jobId, result.sessionId);
|
|
1220
1309
|
messages.add({ id: `${result.jobId}:user`, missionId: req.params.missionId, role: "user", text, createdAt: Date.now(), jobId: result.jobId });
|
|
1221
1310
|
events.add({ ts: Date.now(), sessionId, jobId: result.jobId, source: "custom", phase: "decision_resolved", activityKind: "decision", detail: answer, decisionId: req.params.decisionId });
|
|
@@ -64,3 +64,7 @@ export function jobsToQuiesce(input) {
|
|
|
64
64
|
}
|
|
65
65
|
return stop;
|
|
66
66
|
}
|
|
67
|
+
/** The old Claude background job is replaced, not continued, by `--resume --bg`. */
|
|
68
|
+
export function replacedManagerAfterResume(previousJobId, nextJobId) {
|
|
69
|
+
return previousJobId && previousJobId !== nextJobId ? previousJobId : undefined;
|
|
70
|
+
}
|
|
@@ -54,3 +54,23 @@ export function parseLoopCommand(text) {
|
|
|
54
54
|
}
|
|
55
55
|
return { kind: "start", intervalMs, prompt };
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* `/loop …` typed where a mission is *started* rather than into one that is
|
|
59
|
+
* already running.
|
|
60
|
+
*
|
|
61
|
+
* The mission runs the loop's own prompt — its opening turn is the first pass
|
|
62
|
+
* — and Hive keeps the schedule around it. Listing or stopping needs a mission
|
|
63
|
+
* to act on, so those are refused here rather than silently starting one whose
|
|
64
|
+
* objective is the word "list".
|
|
65
|
+
*/
|
|
66
|
+
export function parseMissionStartLoop(task) {
|
|
67
|
+
const command = parseLoopCommand(task);
|
|
68
|
+
if (!command)
|
|
69
|
+
return { prompt: task };
|
|
70
|
+
if (command.kind === "error")
|
|
71
|
+
return { error: command.message };
|
|
72
|
+
if (command.kind !== "start") {
|
|
73
|
+
return { error: "A loop can only be listed or stopped from a mission that is already running — its conversation, or the Tasks panel." };
|
|
74
|
+
}
|
|
75
|
+
return { prompt: command.prompt, loop: { intervalMs: command.intervalMs, prompt: command.prompt } };
|
|
76
|
+
}
|
|
@@ -95,13 +95,37 @@ export class MessagesStore {
|
|
|
95
95
|
...(input.kind ? { kind: input.kind } : {}),
|
|
96
96
|
...(input.attachments?.length ? { attachments: input.attachments } : {}),
|
|
97
97
|
};
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
98
|
+
// The job watcher can observe a newly launched turn before its request
|
|
99
|
+
// route returns. In that window syncJobs inserts the provider's enriched
|
|
100
|
+
// intent (policy, repository grants, etc.) under `<jobId>:user`. The route
|
|
101
|
+
// write is authoritative for what the person actually typed, so replace
|
|
102
|
+
// that internal prompt instead of appending a second user bubble.
|
|
103
|
+
const canonicalUserId = input.id === `${input.jobId}:user` && input.role === "user";
|
|
104
|
+
const id = canonicalUserId
|
|
105
|
+
? this.replaceCanonicalUserMessage({ ...message, missionId: input.missionId, jobId: input.jobId || "" })
|
|
106
|
+
: this.appendSynced({ ...message, missionId: input.missionId, jobId: input.jobId || "" });
|
|
102
107
|
this.notify();
|
|
103
108
|
return { ...message, id };
|
|
104
109
|
}
|
|
110
|
+
replaceCanonicalUserMessage(message) {
|
|
111
|
+
this.db.transaction(() => {
|
|
112
|
+
// Older builds could already have appended the authoritative text under
|
|
113
|
+
// a content-suffixed id. Collapse any such race artifacts for this turn.
|
|
114
|
+
this.db.prepare("DELETE FROM mission_messages WHERE id LIKE ? AND id <> ?").run(`${message.id}:%`, message.id);
|
|
115
|
+
this.db.prepare(`
|
|
116
|
+
INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id, kind, attachments, noop)
|
|
117
|
+
VALUES (@id, @missionId, @role, @text, @createdAt, @jobId, @kind, @attachments, @noop)
|
|
118
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
119
|
+
mission_id = excluded.mission_id,
|
|
120
|
+
role = excluded.role,
|
|
121
|
+
text = excluded.text,
|
|
122
|
+
job_id = excluded.job_id,
|
|
123
|
+
kind = excluded.kind,
|
|
124
|
+
attachments = excluded.attachments
|
|
125
|
+
`).run({ ...message, kind: message.kind ?? null, attachments: message.attachments?.length ? JSON.stringify(message.attachments) : null, noop: message.noop ? 1 : null });
|
|
126
|
+
})();
|
|
127
|
+
return message.id;
|
|
128
|
+
}
|
|
105
129
|
page(missionId, options = {}) {
|
|
106
130
|
const limit = Math.min(Math.max(options.limit ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE);
|
|
107
131
|
const rows = this.db
|
|
@@ -18,3 +18,10 @@ export function isWorkEvent(event) {
|
|
|
18
18
|
export function reopensOnWork(status) {
|
|
19
19
|
return status === "ready_for_review";
|
|
20
20
|
}
|
|
21
|
+
/** A completed follow-up returns work to the review state it came from. */
|
|
22
|
+
export function returnsToReviewOnStop(mission, hasPendingDecision) {
|
|
23
|
+
return mission.lifecycleStatus === "active"
|
|
24
|
+
&& mission.mode === "orchestrated"
|
|
25
|
+
&& mission.currentState === "Working again after a follow-up"
|
|
26
|
+
&& !hasPendingDecision;
|
|
27
|
+
}
|
|
@@ -67,6 +67,101 @@ export function suggestDirectories(input) {
|
|
|
67
67
|
function stripWorktree(value) {
|
|
68
68
|
return value.split(`${path.sep}.claude${path.sep}worktrees${path.sep}`)[0];
|
|
69
69
|
}
|
|
70
|
+
/** Directory entries at `base` matching `prefix`, formatted by the caller and capped for a completion list. */
|
|
71
|
+
function listMatchingEntries(base, prefix, format) {
|
|
72
|
+
let entries = [];
|
|
73
|
+
try {
|
|
74
|
+
entries = fs.readdirSync(base, { withFileTypes: true })
|
|
75
|
+
.filter((entry) => !entry.name.startsWith(".") && entry.name.toLowerCase().startsWith(prefix))
|
|
76
|
+
.map((entry) => ({ path: format(entry.name, path.join(base, entry.name)), isDirectory: entry.isDirectory() }));
|
|
77
|
+
}
|
|
78
|
+
catch { /* an incomplete path simply has no suggestions yet */ }
|
|
79
|
+
return entries.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.path.localeCompare(b.path)).slice(0, 20);
|
|
80
|
+
}
|
|
81
|
+
/** "src/App.t" style — resolved under the mission's own cwd, and never escapes it. */
|
|
82
|
+
function suggestRelativePathEntries(cwd, trimmed) {
|
|
83
|
+
const slash = trimmed.lastIndexOf("/");
|
|
84
|
+
const dirPart = slash === -1 ? "" : trimmed.slice(0, slash);
|
|
85
|
+
const prefix = (slash === -1 ? trimmed : trimmed.slice(slash + 1)).toLowerCase();
|
|
86
|
+
const root = path.resolve(cwd);
|
|
87
|
+
const base = path.resolve(root, dirPart);
|
|
88
|
+
if (base !== root && !base.startsWith(`${root}${path.sep}`))
|
|
89
|
+
return [];
|
|
90
|
+
return listMatchingEntries(base, prefix, (name) => (dirPart ? `${dirPart}/${name}` : name));
|
|
91
|
+
}
|
|
92
|
+
/** "~/Development/other-repo/" or "/abs/path" style — browses anywhere on disk, spelled back the way it was typed. */
|
|
93
|
+
function suggestRootedPathEntries(trimmed) {
|
|
94
|
+
const expanded = resolveWorkingDirectory(trimmed);
|
|
95
|
+
const listChildren = trimmed.endsWith("/") || trimmed === "~";
|
|
96
|
+
const base = listChildren ? expanded : path.dirname(expanded);
|
|
97
|
+
const prefix = listChildren ? "" : path.basename(expanded).toLowerCase();
|
|
98
|
+
const home = os.homedir();
|
|
99
|
+
return listMatchingEntries(base, prefix, (_name, full) => (trimmed.startsWith("~") && (full === home || full.startsWith(`${home}${path.sep}`))
|
|
100
|
+
? `~/${path.relative(home, full)}`
|
|
101
|
+
: full).split(path.sep).join("/"));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Files and directories matching `query` — the data behind live "@path"
|
|
105
|
+
* completion in a composer. A plain query ("packages/web/s") is resolved
|
|
106
|
+
* under the mission's own `cwd` and cannot escape it; a query that already
|
|
107
|
+
* names a home- or filesystem-rooted path ("~/Development/other-repo/",
|
|
108
|
+
* "/abs/path") browses from there instead, so `@` can also reach a sibling
|
|
109
|
+
* repository directly rather than only what `#` already covers by name.
|
|
110
|
+
*/
|
|
111
|
+
export function suggestPathEntries(cwd, query) {
|
|
112
|
+
const trimmed = query.replace(/^\.\//, "");
|
|
113
|
+
return trimmed.startsWith("~") || trimmed.startsWith("/")
|
|
114
|
+
? suggestRootedPathEntries(trimmed)
|
|
115
|
+
: suggestRelativePathEntries(cwd, trimmed);
|
|
116
|
+
}
|
|
117
|
+
/** Every git repository under `roots`, unranked — a name search must see all of them, not just the recently touched ones. */
|
|
118
|
+
function discoverRepositories(roots) {
|
|
119
|
+
const candidates = new Set();
|
|
120
|
+
for (const root of roots) {
|
|
121
|
+
try {
|
|
122
|
+
const topLevel = git(root, "rev-parse", "--show-toplevel");
|
|
123
|
+
if (topLevel)
|
|
124
|
+
candidates.add(topLevel);
|
|
125
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
126
|
+
if (!entry.isDirectory() || entry.name.startsWith("."))
|
|
127
|
+
continue;
|
|
128
|
+
const candidate = path.join(root, entry.name);
|
|
129
|
+
if (fs.existsSync(path.join(candidate, ".git")))
|
|
130
|
+
candidates.add(candidate);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch { /* unavailable discovery root */ }
|
|
134
|
+
}
|
|
135
|
+
return [...candidates];
|
|
136
|
+
}
|
|
137
|
+
/** Every repository name Hive can resolve, mapped to its absolute path — siblings first, then recently used ones. */
|
|
138
|
+
function repositoryRegistry(baseRepository) {
|
|
139
|
+
const registry = new Map();
|
|
140
|
+
for (const repo of knownRepositories(baseRepository))
|
|
141
|
+
registry.set(repo.name, repo.path);
|
|
142
|
+
for (const resolvedPath of discoverRepositories([defaultWorkingDirectory(), path.join(os.homedir(), "Development")])) {
|
|
143
|
+
const name = path.basename(resolvedPath).toLowerCase();
|
|
144
|
+
if (!registry.has(name))
|
|
145
|
+
registry.set(name, resolvedPath);
|
|
146
|
+
}
|
|
147
|
+
return registry;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Repository names starting with `query`, for live "#repo" completion. The
|
|
151
|
+
* mission's own repository is included rather than hidden — a message can
|
|
152
|
+
* legitimately name it too (e.g. describing work that spans several repos)
|
|
153
|
+
* — but flagged as `isCurrent` so the composer can mark it and a reader
|
|
154
|
+
* isn't left assuming a mention always points somewhere else.
|
|
155
|
+
*/
|
|
156
|
+
export function suggestRepositories(cwd, query) {
|
|
157
|
+
const lower = query.trim().toLowerCase();
|
|
158
|
+
const own = cwd ? path.basename(stripWorktree(cwd)).toLowerCase() : undefined;
|
|
159
|
+
return [...repositoryRegistry(cwd)]
|
|
160
|
+
.filter(([name]) => name.startsWith(lower))
|
|
161
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
162
|
+
.slice(0, 10)
|
|
163
|
+
.map(([name, repoPath]) => ({ name, path: repoPath, isCurrent: name === own }));
|
|
164
|
+
}
|
|
70
165
|
/** Git repositories that sit beside the mission's repo (and the Hive default). */
|
|
71
166
|
export function knownRepositories(baseRepository) {
|
|
72
167
|
const roots = new Set();
|
|
@@ -92,13 +187,24 @@ export function knownRepositories(baseRepository) {
|
|
|
92
187
|
* Repositories a message refers to by name ("add this to monorepo"), so a
|
|
93
188
|
* mission can be granted cross-repo access without any explicit form field.
|
|
94
189
|
* The mission's own repository never counts as a mention.
|
|
190
|
+
*
|
|
191
|
+
* An explicit "#name" mention (the composer's own autocomplete syntax) is
|
|
192
|
+
* resolved against the full repository registry — siblings and recently
|
|
193
|
+
* used repositories both — while the implicit bare-word heuristic stays
|
|
194
|
+
* scoped to siblings only, exactly as it always has, so existing behavior
|
|
195
|
+
* is unaffected by adding the explicit form alongside it.
|
|
95
196
|
*/
|
|
96
197
|
export function detectRepositoryMentions(text, ownRepository) {
|
|
97
198
|
const own = ownRepository ? path.basename(stripWorktree(ownRepository)).toLowerCase() : undefined;
|
|
98
199
|
const lower = text.toLowerCase();
|
|
99
|
-
|
|
200
|
+
const implicit = knownRepositories(ownRepository)
|
|
100
201
|
.filter(({ name }) => name !== own && new RegExp(`(?<![\\w-])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w-])`).test(lower))
|
|
101
202
|
.map((repo) => repo.path);
|
|
203
|
+
const explicitNames = new Set([...text.matchAll(/#([\w.-]+)/g)].map((match) => match[1].toLowerCase()));
|
|
204
|
+
const explicit = explicitNames.size
|
|
205
|
+
? [...repositoryRegistry(ownRepository)].filter(([name]) => name !== own && explicitNames.has(name)).map(([, repoPath]) => repoPath)
|
|
206
|
+
: [];
|
|
207
|
+
return [...new Set([...implicit, ...explicit])];
|
|
102
208
|
}
|
|
103
209
|
/**
|
|
104
210
|
* The known repository containing filePath, excluding the mission's own repo
|
|
@@ -144,24 +250,13 @@ export function inspectWorkingDirectory(input) {
|
|
|
144
250
|
}
|
|
145
251
|
export function recentRepositories(limit = 8) {
|
|
146
252
|
const roots = [defaultWorkingDirectory(), path.join(os.homedir(), "Development")];
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
try {
|
|
150
|
-
if (git(root, "rev-parse", "--show-toplevel"))
|
|
151
|
-
candidates.add(git(root, "rev-parse", "--show-toplevel"));
|
|
152
|
-
for (const entry of fs.readdirSync(root, { withFileTypes: true }))
|
|
153
|
-
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
154
|
-
const candidate = path.join(root, entry.name);
|
|
155
|
-
if (fs.existsSync(path.join(candidate, ".git")))
|
|
156
|
-
candidates.add(candidate);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
catch { /* unavailable discovery root */ }
|
|
160
|
-
}
|
|
161
|
-
return [...candidates].sort((a, b) => { try {
|
|
253
|
+
return discoverRepositories(roots)
|
|
254
|
+
.sort((a, b) => { try {
|
|
162
255
|
return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs;
|
|
163
256
|
}
|
|
164
257
|
catch {
|
|
165
258
|
return 0;
|
|
166
|
-
} })
|
|
259
|
+
} })
|
|
260
|
+
.slice(0, limit)
|
|
261
|
+
.map((resolvedPath) => ({ path: displayPath(resolvedPath), resolvedPath }));
|
|
167
262
|
}
|