@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 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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shanesaravia/hive",
3
3
  "private": false,
4
- "version": "0.4.0",
4
+ "version": "0.5.0",
5
5
  "type": "module",
6
6
  "description": "Provider-neutral local mission control for Claude Code, Codex, and agent fleets.",
7
7
  "license": "MIT",
@@ -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 sendMessage(target.sessionId, prompt, policy, additionalRepos);
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(task, resolvedCwd);
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(task, provider, new Set(discoverSkills(resolvedCwd, provider).map((skill) => skill.name)));
862
- const prompt = providerTask + templatePrompt + limits + policyPrompt + repoPrompt + planFirstPrompt;
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: task,
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
- if (missions.get(missionId))
1037
- await quiesceMission(missionId, session.jobId);
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 sendMessage(sessionId, text, grant ? { ...policy, allowedTools: [...policy.allowedTools, grant] } : policy);
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
- // Callers key on the job (`<jobId>:user`), so a retried turn can collide
99
- // with a message already on screen. Append rather than rewrite it, and
100
- // report the id the text actually landed under.
101
- const id = this.appendSynced({ ...message, missionId: input.missionId, jobId: input.jobId || "" });
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
@@ -74,7 +74,7 @@ export class MissionsStore {
74
74
  return groups.length;
75
75
  }
76
76
  create(input) {
77
- const id = randomUUID();
77
+ const id = input.id ?? randomUUID();
78
78
  const now = Date.now();
79
79
  const mission = {
80
80
  id,
@@ -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
- return knownRepositories(ownRepository)
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
- const candidates = new Set();
148
- for (const root of roots) {
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
- } }).slice(0, limit).map((resolvedPath) => ({ path: displayPath(resolvedPath), resolvedPath }));
259
+ } })
260
+ .slice(0, limit)
261
+ .map((resolvedPath) => ({ path: displayPath(resolvedPath), resolvedPath }));
167
262
  }