@shanesaravia/hive 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +18 -1
  3. package/node_modules/@hive/shared/dist/directStudio.d.ts +6 -0
  4. package/node_modules/@hive/shared/dist/directStudio.js +12 -0
  5. package/node_modules/@hive/shared/dist/index.d.ts +2 -0
  6. package/node_modules/@hive/shared/dist/index.js +2 -0
  7. package/node_modules/@hive/shared/dist/reviewHall.d.ts +15 -0
  8. package/node_modules/@hive/shared/dist/reviewHall.js +49 -0
  9. package/node_modules/@hive/shared/dist/status.js +9 -0
  10. package/node_modules/@hive/shared/dist/types.d.ts +191 -1
  11. package/node_modules/@hive/shared/dist/types.js +27 -0
  12. package/node_modules/@hive/shared/dist/workers.d.ts +14 -0
  13. package/node_modules/@hive/shared/dist/workers.js +22 -0
  14. package/package.json +1 -1
  15. package/packages/server/dist/agents/agentDiscovery.js +64 -0
  16. package/packages/server/dist/api/rest.js +538 -23
  17. package/packages/server/dist/api/ws.js +90 -9
  18. package/packages/server/dist/control/launcher.js +50 -11
  19. package/packages/server/dist/control/messaging.js +3 -2
  20. package/packages/server/dist/control/missionQuiesce.js +66 -0
  21. package/packages/server/dist/health/deriveAlerts.js +9 -2
  22. package/packages/server/dist/hooks/hookIngest.js +69 -10
  23. package/packages/server/dist/index.js +44 -4
  24. package/packages/server/dist/loops/loopCommand.js +56 -0
  25. package/packages/server/dist/loops/loopNoop.js +38 -0
  26. package/packages/server/dist/loops/loopScheduler.js +58 -0
  27. package/packages/server/dist/loops/loopStore.js +118 -0
  28. package/packages/server/dist/loops/monitors.js +38 -0
  29. package/packages/server/dist/messages/attachmentStore.js +92 -0
  30. package/packages/server/dist/messages/messagesStore.js +110 -33
  31. package/packages/server/dist/missions/missionsStore.js +9 -0
  32. package/packages/server/dist/missions/reopenOnWork.js +20 -0
  33. package/packages/server/dist/plans/planReconcile.js +114 -0
  34. package/packages/server/dist/plans/plansStore.js +46 -3
  35. package/packages/server/dist/reviews/reviewDiff.js +47 -0
  36. package/packages/server/dist/roster/missionReplay.js +82 -0
  37. package/packages/server/dist/roster/replyAsk.js +62 -0
  38. package/packages/server/dist/roster/rosterBuilder.js +78 -147
  39. package/packages/server/dist/roster/workerIdentity.js +923 -0
  40. package/packages/server/dist/skills/skillDiscovery.js +28 -4
  41. package/packages/server/dist/terminals/claudeStreamClient.js +90 -0
  42. package/packages/server/dist/terminals/codexAppServerClient.js +195 -0
  43. package/packages/server/dist/terminals/providerDetection.js +27 -0
  44. package/packages/server/dist/terminals/terminalCapability.js +45 -0
  45. package/packages/server/dist/terminals/terminalFeatures.js +11 -0
  46. package/packages/server/dist/terminals/terminalObservability.js +21 -0
  47. package/packages/server/dist/terminals/terminalRuntime.js +125 -0
  48. package/packages/server/dist/terminals/terminalStream.js +30 -0
  49. package/packages/server/dist/transcripts/transcriptReader.js +345 -0
  50. package/packages/server/dist/watch/jobsWatcher.js +55 -24
  51. package/packages/web/dist/assets/index-DWjqiitn.js +17 -0
  52. package/packages/web/dist/assets/index-rd4RnLqj.css +2 -0
  53. package/packages/web/dist/index.html +2 -2
  54. package/templates/agents/hive-orchestrator.md +1 -0
  55. package/packages/web/dist/assets/index-Bzle5Xla.css +0 -2
  56. package/packages/web/dist/assets/index-C6AY0vYC.js +0 -11
@@ -0,0 +1,58 @@
1
+ import { withLoopInstructions } from "./loopNoop.js";
2
+ export const LOOP_TICK_MS = 15_000;
3
+ /**
4
+ * Fires due loops.
5
+ *
6
+ * A loop never overlaps itself: the run is marked before the send, so a slow
7
+ * turn delays the next firing rather than stacking a second one behind it.
8
+ * A mission that has gone away stops its loops instead of failing forever.
9
+ */
10
+ export class LoopScheduler {
11
+ deps;
12
+ timer;
13
+ running = new Set();
14
+ constructor(deps) {
15
+ this.deps = deps;
16
+ }
17
+ start(intervalMs = LOOP_TICK_MS) {
18
+ if (this.timer)
19
+ return;
20
+ this.timer = setInterval(() => { void this.tick(); }, intervalMs);
21
+ this.timer.unref?.();
22
+ }
23
+ stop() {
24
+ if (this.timer)
25
+ clearInterval(this.timer);
26
+ this.timer = undefined;
27
+ }
28
+ async tick(now = Date.now()) {
29
+ for (const loop of this.deps.loops.due(now)) {
30
+ if (this.running.has(loop.id))
31
+ continue;
32
+ if (!this.deps.isRunnable(loop.missionId)) {
33
+ this.deps.loops.stop(loop.id, "the mission is no longer running");
34
+ continue;
35
+ }
36
+ // Deferred, not skipped: nextRunAt stays in the past, so the loop fires
37
+ // on the first tick after the mission goes quiet rather than losing its
38
+ // turn or interrupting one.
39
+ if (this.deps.isBusy(loop.missionId))
40
+ continue;
41
+ this.running.add(loop.id);
42
+ // Marked before the send: a turn that outlasts the interval must push the
43
+ // next firing out, never queue another on top of it.
44
+ this.deps.loops.markRun(loop.id, now);
45
+ try {
46
+ // The stored prompt stays exactly what was asked for; the folding
47
+ // instruction is added only on the way out.
48
+ await this.deps.send(loop.missionId, withLoopInstructions(loop.prompt));
49
+ }
50
+ catch (error) {
51
+ this.deps.onError?.(loop, error);
52
+ }
53
+ finally {
54
+ this.running.delete(loop.id);
55
+ }
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,118 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import Database from "better-sqlite3";
3
+ import { config } from "../config.js";
4
+ function toLoop(row) {
5
+ return {
6
+ id: row.id,
7
+ missionId: row.mission_id,
8
+ prompt: row.prompt,
9
+ intervalMs: row.interval_ms,
10
+ status: row.status,
11
+ createdAt: row.created_at,
12
+ ...(row.last_run_at ? { lastRunAt: row.last_run_at } : {}),
13
+ nextRunAt: row.next_run_at,
14
+ runCount: row.run_count,
15
+ ...(row.stopped_reason ? { stoppedReason: row.stopped_reason } : {}),
16
+ };
17
+ }
18
+ /**
19
+ * Recurring prompts Hive re-sends to a mission.
20
+ *
21
+ * Persisted rather than held in memory: a loop that quietly dies when the
22
+ * server restarts is a loop the reader still believes is running, which is the
23
+ * failure this whole feature exists to prevent.
24
+ */
25
+ export class LoopStore {
26
+ db;
27
+ listeners = new Set();
28
+ constructor(filePath = config.databasePath) {
29
+ this.db = new Database(filePath);
30
+ this.db.pragma("journal_mode = WAL");
31
+ this.db.exec(`
32
+ CREATE TABLE IF NOT EXISTS mission_loops (
33
+ id TEXT PRIMARY KEY,
34
+ mission_id TEXT NOT NULL,
35
+ prompt TEXT NOT NULL,
36
+ interval_ms INTEGER NOT NULL,
37
+ status TEXT NOT NULL,
38
+ created_at INTEGER NOT NULL,
39
+ last_run_at INTEGER,
40
+ next_run_at INTEGER NOT NULL,
41
+ run_count INTEGER NOT NULL DEFAULT 0,
42
+ stopped_reason TEXT
43
+ );
44
+ CREATE INDEX IF NOT EXISTS idx_mission_loops_mission ON mission_loops (mission_id, status);
45
+ CREATE INDEX IF NOT EXISTS idx_mission_loops_due ON mission_loops (status, next_run_at);
46
+ `);
47
+ }
48
+ close() { this.db.close(); }
49
+ onChange(listener) { this.listeners.add(listener); }
50
+ notify() { for (const listener of this.listeners)
51
+ listener(); }
52
+ create(input) {
53
+ const now = input.now ?? Date.now();
54
+ const loop = {
55
+ id: randomUUID().slice(0, 8),
56
+ mission_id: input.missionId,
57
+ prompt: input.prompt,
58
+ interval_ms: input.intervalMs,
59
+ status: "active",
60
+ created_at: now,
61
+ last_run_at: null,
62
+ // The first run is one interval away, not immediate: the message that
63
+ // created the loop is itself the first pass.
64
+ next_run_at: now + input.intervalMs,
65
+ run_count: 0,
66
+ stopped_reason: null,
67
+ };
68
+ this.db.prepare(`
69
+ INSERT INTO mission_loops (id, mission_id, prompt, interval_ms, status, created_at, last_run_at, next_run_at, run_count, stopped_reason)
70
+ VALUES (@id, @mission_id, @prompt, @interval_ms, @status, @created_at, @last_run_at, @next_run_at, @run_count, @stopped_reason)
71
+ `).run(loop);
72
+ this.notify();
73
+ return toLoop(loop);
74
+ }
75
+ get(id) {
76
+ const row = this.db.prepare("SELECT * FROM mission_loops WHERE id = ?").get(id);
77
+ return row ? toLoop(row) : undefined;
78
+ }
79
+ /** Active loops, soonest first. Stopped ones are history, not state. */
80
+ active(missionId) {
81
+ const rows = missionId
82
+ ? this.db.prepare("SELECT * FROM mission_loops WHERE status = 'active' AND mission_id = ? ORDER BY next_run_at").all(missionId)
83
+ : this.db.prepare("SELECT * FROM mission_loops WHERE status = 'active' ORDER BY next_run_at").all();
84
+ return rows.map(toLoop);
85
+ }
86
+ due(now = Date.now()) {
87
+ return this.db.prepare("SELECT * FROM mission_loops WHERE status = 'active' AND next_run_at <= ? ORDER BY next_run_at").all(now).map(toLoop);
88
+ }
89
+ /** Records a firing and schedules the next one from now, not from the due time. */
90
+ markRun(id, now = Date.now()) {
91
+ this.db.prepare(`
92
+ UPDATE mission_loops
93
+ SET last_run_at = @now, next_run_at = @now + interval_ms, run_count = run_count + 1
94
+ WHERE id = @id AND status = 'active'
95
+ `).run({ id, now });
96
+ this.notify();
97
+ }
98
+ stop(id, reason) {
99
+ const result = this.db
100
+ .prepare("UPDATE mission_loops SET status = 'stopped', stopped_reason = ? WHERE id = ? AND status = 'active'")
101
+ .run(reason ?? null, id);
102
+ if (result.changes)
103
+ this.notify();
104
+ return result.changes > 0;
105
+ }
106
+ stopMission(missionId, reason) {
107
+ const result = this.db
108
+ .prepare("UPDATE mission_loops SET status = 'stopped', stopped_reason = ? WHERE mission_id = ? AND status = 'active'")
109
+ .run(reason ?? null, missionId);
110
+ if (result.changes)
111
+ this.notify();
112
+ return result.changes;
113
+ }
114
+ removeMission(missionId) {
115
+ this.db.prepare("DELETE FROM mission_loops WHERE mission_id = ?").run(missionId);
116
+ this.notify();
117
+ }
118
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Background watchers a mission's agent has open, each by name.
3
+ *
4
+ * They come from the job's fan rather than the `inFlight.kinds` tally, which
5
+ * only ever yields a count — and a count cannot tell you whether the watcher
6
+ * you are looking for is the one still running.
7
+ *
8
+ * Hive does not start these and the CLI has no per-watcher stop (`claude stop`
9
+ * takes a session), so the turn that owns them is what can actually be ended.
10
+ * Naming them is what makes that choice an informed one.
11
+ */
12
+ export function activeMonitors(jobs, missionFor, missionName) {
13
+ const monitors = [];
14
+ for (const [jobId, job] of jobs) {
15
+ // A finished turn's watchers are finished with it. Real job records keep
16
+ // fan entries with no doneAt long after the turn ended, so trusting the
17
+ // entry alone would report watchers that stopped days ago.
18
+ if (job.state === "done" || job.state === "failed")
19
+ continue;
20
+ const missionId = missionFor(jobId);
21
+ if (!missionId)
22
+ continue;
23
+ for (const entry of job.fan ?? []) {
24
+ if (entry.kind !== "monitor" || entry.doneAt)
25
+ continue;
26
+ monitors.push({
27
+ missionId,
28
+ missionName: missionName(missionId),
29
+ jobId,
30
+ id: entry.id,
31
+ label: entry.label?.trim() || "unnamed watcher",
32
+ startedAt: entry.startedAt || Date.parse(job.createdAt ?? "") || 0,
33
+ ...(job.detail ? { detail: job.detail } : {}),
34
+ });
35
+ }
36
+ }
37
+ return monitors.sort((a, b) => a.startedAt - b.startedAt);
38
+ }
@@ -0,0 +1,92 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { config } from "../config.js";
5
+ /** What a pasted screenshot can actually be, and the extension it lands under. */
6
+ const EXTENSIONS = {
7
+ "image/png": "png",
8
+ "image/jpeg": "jpg",
9
+ "image/gif": "gif",
10
+ "image/webp": "webp",
11
+ };
12
+ export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
13
+ export function isSupportedMediaType(mediaType) {
14
+ return mediaType in EXTENSIONS;
15
+ }
16
+ /**
17
+ * Images pasted into a mission conversation, written to disk so the agent can
18
+ * open them.
19
+ *
20
+ * The model does not receive bytes — Hive hands the session an absolute path
21
+ * and the agent reads it with its own Read tool, which is the one route that
22
+ * works identically for every provider and needs no protocol of its own.
23
+ */
24
+ export class AttachmentStore {
25
+ root;
26
+ constructor(root = path.join(config.hiveDataDir, "attachments")) {
27
+ this.root = root;
28
+ }
29
+ /** Ids are opaque and generated here, so a caller can never address a path. */
30
+ fileFor(missionId, id) {
31
+ const dir = path.join(this.root, encodeURIComponent(missionId));
32
+ let entries;
33
+ try {
34
+ entries = fs.readdirSync(dir);
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ const match = entries.find((entry) => path.basename(entry, path.extname(entry)) === id);
40
+ return match ? path.join(dir, match) : undefined;
41
+ }
42
+ save(missionId, input) {
43
+ const extension = EXTENSIONS[input.mediaType];
44
+ if (!extension)
45
+ throw new Error(`unsupported attachment type: ${input.mediaType}`);
46
+ if (input.data.byteLength > MAX_ATTACHMENT_BYTES)
47
+ throw new Error("attachment is larger than 10MB");
48
+ const id = randomUUID();
49
+ const dir = path.join(this.root, encodeURIComponent(missionId));
50
+ fs.mkdirSync(dir, { recursive: true });
51
+ const file = path.join(dir, `${id}.${extension}`);
52
+ fs.writeFileSync(file, input.data);
53
+ return {
54
+ id,
55
+ name: input.name?.trim() || `pasted-image.${extension}`,
56
+ mediaType: input.mediaType,
57
+ bytes: input.data.byteLength,
58
+ path: file,
59
+ url: `/api/mission/${encodeURIComponent(missionId)}/attachment/${id}`,
60
+ };
61
+ }
62
+ read(missionId, id) {
63
+ const file = this.fileFor(missionId, id);
64
+ if (!file)
65
+ return undefined;
66
+ const extension = path.extname(file).slice(1);
67
+ const mediaType = Object.entries(EXTENSIONS).find(([, value]) => value === extension)?.[0];
68
+ return mediaType ? { file, mediaType } : undefined;
69
+ }
70
+ /** The stored descriptor, rebuilt from disk so a caller cannot assert one. */
71
+ describe(missionId, id, name) {
72
+ const found = this.read(missionId, id);
73
+ if (!found)
74
+ return undefined;
75
+ let bytes = 0;
76
+ try {
77
+ bytes = fs.statSync(found.file).size;
78
+ }
79
+ catch { /* reported as zero */ }
80
+ return {
81
+ id,
82
+ name: name?.trim() || path.basename(found.file),
83
+ mediaType: found.mediaType,
84
+ bytes,
85
+ path: found.file,
86
+ url: `/api/mission/${encodeURIComponent(missionId)}/attachment/${id}`,
87
+ };
88
+ }
89
+ removeMission(missionId) {
90
+ fs.rmSync(path.join(this.root, encodeURIComponent(missionId)), { recursive: true, force: true });
91
+ }
92
+ }
@@ -1,6 +1,7 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import Database from "better-sqlite3";
3
3
  import { config } from "../config.js";
4
+ import { stripLoopInstructions, stripNoopMarker } from "../loops/loopNoop.js";
4
5
  const DEFAULT_PAGE_SIZE = 30;
5
6
  const MAX_PAGE_SIZE = 100;
6
7
  export const MODEL_CONTEXT_MESSAGE_LIMIT = 12;
@@ -16,6 +17,14 @@ function outputText(job) {
16
17
  }
17
18
  return undefined;
18
19
  }
20
+ /**
21
+ * Stable suffix derived from the text itself, so re-running a sync over the
22
+ * same content is a no-op while genuinely different content lands under its own
23
+ * id instead of overwriting what is already on screen.
24
+ */
25
+ export function contentKey(text) {
26
+ return createHash("sha1").update(text).digest("hex").slice(0, 12);
27
+ }
19
28
  function timestamp(value) {
20
29
  const parsed = value ? Date.parse(value) : NaN;
21
30
  return Number.isNaN(parsed) ? Date.now() : parsed;
@@ -27,6 +36,9 @@ function toMessage(row) {
27
36
  text: row.text,
28
37
  createdAt: row.created_at,
29
38
  jobId: row.job_id ?? "",
39
+ ...(row.kind === "update" ? { kind: "update" } : {}),
40
+ ...(row.attachments ? { attachments: JSON.parse(row.attachments) } : {}),
41
+ ...(row.noop ? { noop: true } : {}),
30
42
  };
31
43
  }
32
44
  /** Indexed, paginated transcript storage. History is never prompt context by default. */
@@ -44,13 +56,28 @@ export class MessagesStore {
44
56
  role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
45
57
  text TEXT NOT NULL,
46
58
  created_at INTEGER NOT NULL,
47
- job_id TEXT
59
+ job_id TEXT,
60
+ kind TEXT,
61
+ attachments TEXT,
62
+ noop INTEGER
48
63
  );
49
64
  CREATE INDEX IF NOT EXISTS idx_mission_messages_page
50
65
  ON mission_messages (mission_id, created_at DESC, id DESC);
51
66
  CREATE INDEX IF NOT EXISTS idx_mission_messages_job
52
67
  ON mission_messages (job_id);
53
68
  `);
69
+ try {
70
+ this.db.exec("ALTER TABLE mission_messages ADD COLUMN kind TEXT");
71
+ }
72
+ catch { /* already present */ }
73
+ try {
74
+ this.db.exec("ALTER TABLE mission_messages ADD COLUMN attachments TEXT");
75
+ }
76
+ catch { /* already present */ }
77
+ try {
78
+ this.db.exec("ALTER TABLE mission_messages ADD COLUMN noop INTEGER");
79
+ }
80
+ catch { /* already present */ }
54
81
  }
55
82
  close() {
56
83
  this.db.close();
@@ -65,26 +92,21 @@ export class MessagesStore {
65
92
  text: input.text,
66
93
  createdAt: input.createdAt,
67
94
  jobId: input.jobId,
95
+ ...(input.kind ? { kind: input.kind } : {}),
96
+ ...(input.attachments?.length ? { attachments: input.attachments } : {}),
68
97
  };
69
- this.db
70
- .prepare(`
71
- INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id)
72
- VALUES (@id, @missionId, @role, @text, @createdAt, @jobId)
73
- ON CONFLICT(id) DO UPDATE SET
74
- mission_id = excluded.mission_id,
75
- text = excluded.text,
76
- created_at = excluded.created_at,
77
- job_id = excluded.job_id
78
- `)
79
- .run({ ...message, missionId: input.missionId, jobId: input.jobId || null });
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 || "" });
80
102
  this.notify();
81
- return message;
103
+ return { ...message, id };
82
104
  }
83
105
  page(missionId, options = {}) {
84
106
  const limit = Math.min(Math.max(options.limit ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE);
85
107
  const rows = this.db
86
108
  .prepare(`
87
- SELECT id, mission_id, role, text, created_at, job_id
109
+ SELECT id, mission_id, role, text, created_at, job_id, kind, attachments, noop
88
110
  FROM mission_messages
89
111
  WHERE mission_id = @missionId
90
112
  AND (@before IS NULL OR created_at < @before)
@@ -123,7 +145,7 @@ export class MessagesStore {
123
145
  const terms = [...new Set(query.toLowerCase().match(/[a-z0-9_-]{4,}/g) ?? [])].slice(0, 20);
124
146
  if (!terms.length)
125
147
  return [];
126
- const rows = this.db.prepare("SELECT id, mission_id, role, text, created_at, job_id FROM mission_messages WHERE mission_id = ? ORDER BY created_at DESC LIMIT 250").all(missionId);
148
+ const rows = this.db.prepare("SELECT id, mission_id, role, text, created_at, job_id, kind FROM mission_messages WHERE mission_id = ? ORDER BY created_at DESC LIMIT 250").all(missionId);
127
149
  let remaining = chars(CONTEXT_BUDGETS.retrievedTokens);
128
150
  return rows.filter((row) => !excludedIds.has(row.id)).map(toMessage).map((message) => ({ message, score: terms.filter((term) => message.text.toLowerCase().includes(term)).length })).filter((item) => item.score > 0).sort((a, b) => b.score - a.score || b.message.createdAt - a.message.createdAt).slice(0, 6).flatMap(({ message }) => { if (remaining <= 0)
129
151
  return []; const text = message.text.slice(0, remaining); remaining -= text.length; return [{ ...message, text }]; }).sort((a, b) => a.createdAt - b.createdAt);
@@ -140,7 +162,7 @@ export class MessagesStore {
140
162
  id: `${jobId}:user`,
141
163
  missionId,
142
164
  role: "user",
143
- text: job.intent,
165
+ text: stripLoopInstructions(job.intent),
144
166
  createdAt: timestamp(job.createdAt),
145
167
  jobId,
146
168
  });
@@ -153,15 +175,35 @@ export class MessagesStore {
153
175
  // Blocked turns (e.g. awaiting acceptance) also carry a compressed
154
176
  // output.result — the transcript text wins there too when it says more.
155
177
  const fullReply = (job.state === "done" || job.state === "blocked") && job.lastText && job.lastText.length > (output?.length ?? 0) ? job.lastText : undefined;
156
- const result = fullReply
178
+ const rawResult = fullReply
157
179
  ?? output
158
180
  ?? (job.state === "failed" ? `⚠️ This turn failed: ${job.detail ?? "the session exited before producing a reply"}` : undefined)
159
- ?? (job.state === "blocked" ? job.lastText ?? job.needs ?? job.detail : undefined)
160
- // Mid-turn permission prompts: the job stays "working" but tempo
161
- // flips to blocked with the ask in needs (e.g. "approve Bash: …").
162
- ?? (job.tempo === "blocked" && job.needs ? `🔐 ${job.needs}` : undefined);
181
+ ?? (job.state === "blocked" ? job.lastText ?? job.needs ?? job.detail : undefined);
182
+ // A looping agent ends a pass that found nothing with a marker line.
183
+ // It is bookkeeping, so it is split off here and never reaches the
184
+ // transcript only the flag it sets, which folds the run away.
185
+ const quiet = rawResult ? stripNoopMarker(rawResult) : undefined;
186
+ const result = quiet?.text || rawResult;
187
+ // Mid-turn permission prompts: the job stays "working" but tempo flips
188
+ // to blocked with the ask in needs (e.g. "approve Bash: …"). This is a
189
+ // passing event, not the turn's reply — it gets its own appended line
190
+ // rather than sitting in `:assistant`, where the real reply would later
191
+ // overwrite it and the prompt would appear to vanish.
192
+ const prompt = job.tempo === "blocked" && job.needs ? `🔐 ${job.needs}` : undefined;
193
+ // What the model said on the way — narration between tool calls — as
194
+ // compact updates, so the conversation shows the work as it goes
195
+ // rather than a single reply at the end.
196
+ const updates = (job.progressTexts ?? []).filter((text) => text !== result);
197
+ const turnStartedAt = timestamp(job.createdAt);
198
+ // Keyed by content, not by position: the filter above can drop an entry
199
+ // on a later sync, and index-keyed ids would then shift every following
200
+ // bubble onto different text.
201
+ updates.forEach((text, index) => this.appendSynced({ id: `${jobId}:update:${contentKey(text)}`, missionId, role: "assistant", text, createdAt: turnStartedAt + 1 + index, jobId, kind: "update" }));
202
+ if (prompt) {
203
+ this.appendSynced({ id: `${jobId}:prompt:${contentKey(prompt)}`, missionId, role: "assistant", text: prompt, createdAt: timestamp(job.updatedAt ?? job.createdAt), jobId, kind: "update" });
204
+ }
163
205
  if (result) {
164
- this.upsertSynced({
206
+ this.appendSynced({
165
207
  id: `${jobId}:assistant`,
166
208
  missionId,
167
209
  role: "assistant",
@@ -170,6 +212,10 @@ export class MessagesStore {
170
212
  jobId,
171
213
  });
172
214
  }
215
+ // After the reply is written, so the run folds as a whole rather than
216
+ // leaving the newest message behind.
217
+ if (quiet?.noop)
218
+ this.markQuietRun(jobId);
173
219
  }
174
220
  });
175
221
  sync();
@@ -178,31 +224,62 @@ export class MessagesStore {
178
224
  onChange(listener) {
179
225
  this.listeners.add(listener);
180
226
  }
181
- /** Upsert that follows mission relinking but never overwrites existing text. */
227
+ /**
228
+ * Insert that follows mission relinking but never rewrites text — and never
229
+ * rewrites `created_at` either, which is the sort key: moving it would slide
230
+ * an already-read bubble to a different place in the transcript.
231
+ */
182
232
  insertSynced(message) {
183
233
  this.db
184
234
  .prepare(`
185
- INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id)
186
- VALUES (@id, @missionId, @role, @text, @createdAt, @jobId)
235
+ INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id, kind, attachments, noop)
236
+ VALUES (@id, @missionId, @role, @text, @createdAt, @jobId, @kind, @attachments, @noop)
187
237
  ON CONFLICT(id) DO UPDATE SET
188
238
  mission_id = excluded.mission_id,
189
- created_at = excluded.created_at,
190
239
  job_id = excluded.job_id
191
240
  `)
192
- .run(message);
241
+ .run({ ...message, kind: message.kind ?? null, attachments: message.attachments?.length ? JSON.stringify(message.attachments) : null, noop: message.noop ? 1 : null });
242
+ return message.id;
193
243
  }
194
- upsertSynced(message) {
244
+ /**
245
+ * The append-only write, and the reason a bubble the reader has already seen
246
+ * cannot be swapped for a different one.
247
+ *
248
+ * Syncs run repeatedly over the same live job, and its text moves as the turn
249
+ * progresses — a compressed `output.result` arrives before the transcript's
250
+ * fuller `lastText`. Only one kind of in-place edit stays truthful: pure
251
+ * growth, where what is on screen is a prefix of what is arriving, so the
252
+ * message extends rather than changes. Text that genuinely differs is a
253
+ * different thing to say and lands as its own appended message.
254
+ */
255
+ appendSynced(message) {
256
+ const existing = this.db
257
+ .prepare("SELECT text FROM mission_messages WHERE id = ?")
258
+ .get(message.id);
259
+ if (existing && existing.text !== message.text && !message.text.startsWith(existing.text)) {
260
+ return this.insertSynced({ ...message, id: `${message.id}:${contentKey(message.text)}` });
261
+ }
195
262
  this.db
196
263
  .prepare(`
197
- INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id)
198
- VALUES (@id, @missionId, @role, @text, @createdAt, @jobId)
264
+ INSERT INTO mission_messages (id, mission_id, role, text, created_at, job_id, kind, attachments, noop)
265
+ VALUES (@id, @missionId, @role, @text, @createdAt, @jobId, @kind, @attachments, @noop)
199
266
  ON CONFLICT(id) DO UPDATE SET
200
267
  mission_id = excluded.mission_id,
201
268
  text = excluded.text,
202
- created_at = excluded.created_at,
203
269
  job_id = excluded.job_id
204
270
  `)
205
- .run(message);
271
+ .run({ ...message, kind: message.kind ?? null, attachments: message.attachments?.length ? JSON.stringify(message.attachments) : null, noop: message.noop ? 1 : null });
272
+ return message.id;
273
+ }
274
+ /**
275
+ * Flags every message of a turn as a quiet loop pass.
276
+ *
277
+ * Display metadata only — no text is rewritten and nothing moves, so the
278
+ * append-only guarantee holds. The prompt is flagged alongside the reply so
279
+ * a run folds as one unit rather than leaving its question stranded.
280
+ */
281
+ markQuietRun(jobId) {
282
+ this.db.prepare("UPDATE mission_messages SET noop = 1 WHERE job_id = ? AND noop IS NULL").run(jobId);
206
283
  }
207
284
  notify() {
208
285
  for (const listener of this.listeners)
@@ -11,6 +11,8 @@ const emptyData = () => ({
11
11
  deletedJobIds: [],
12
12
  });
13
13
  /** Durable Hive mission identity and metadata, independent of Claude jobs. */
14
+ /** The standing this store writes when a mission reaches review, and only that. */
15
+ const AWAITING_ACCEPTANCE = /^awaiting (user )?acceptance\.?$/i;
14
16
  export class MissionsStore {
15
17
  filePath;
16
18
  legacyThreadsPath;
@@ -203,6 +205,13 @@ export class MissionsStore {
203
205
  setLifecycleStatus(missionId, status) {
204
206
  const mission = this.ensureLegacyMission(missionId);
205
207
  mission.lifecycleStatus = status;
208
+ // Reaching review writes "Awaiting user acceptance" as the mission's
209
+ // standing. Leaving review left it there, so a reopened mission kept
210
+ // telling the board it was waiting for an acceptance nobody could give —
211
+ // and the card, having no chip to explain it, just looked stuck.
212
+ if (status !== "ready_for_review" && AWAITING_ACCEPTANCE.test(mission.currentState?.trim() ?? "")) {
213
+ mission.currentState = undefined;
214
+ }
206
215
  mission.updatedAt = Date.now();
207
216
  this.changed();
208
217
  }
@@ -0,0 +1,20 @@
1
+ export function isWorkEvent(event) {
2
+ // Something was written to the repository.
3
+ if (event.activityKind === "file_write")
4
+ return true;
5
+ // An artifact written through the same tools — an image, a PDF, a document.
6
+ if (event.activityKind === "output" && event.filePath)
7
+ return true;
8
+ // A worker started. Note the hook names rather than activityKind "agent",
9
+ // which also covers a teammate going idle — the opposite of work.
10
+ if (event.hookEventName === "SubagentStart" || event.hookEventName === "TaskCreated")
11
+ return true;
12
+ // The orchestrator saying it is handing work out.
13
+ if (event.source === "custom" && event.phase === "delegating")
14
+ return true;
15
+ return false;
16
+ }
17
+ /** The lifecycle states an observed piece of work should pull back to active. */
18
+ export function reopensOnWork(status) {
19
+ return status === "ready_for_review";
20
+ }