@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.
- package/CHANGELOG.md +53 -0
- package/README.md +18 -1
- package/node_modules/@hive/shared/dist/directStudio.d.ts +6 -0
- package/node_modules/@hive/shared/dist/directStudio.js +12 -0
- package/node_modules/@hive/shared/dist/index.d.ts +2 -0
- package/node_modules/@hive/shared/dist/index.js +2 -0
- package/node_modules/@hive/shared/dist/reviewHall.d.ts +15 -0
- package/node_modules/@hive/shared/dist/reviewHall.js +49 -0
- package/node_modules/@hive/shared/dist/status.js +9 -0
- package/node_modules/@hive/shared/dist/types.d.ts +191 -1
- package/node_modules/@hive/shared/dist/types.js +27 -0
- package/node_modules/@hive/shared/dist/workers.d.ts +14 -0
- package/node_modules/@hive/shared/dist/workers.js +22 -0
- package/package.json +1 -1
- package/packages/server/dist/agents/agentDiscovery.js +64 -0
- package/packages/server/dist/api/rest.js +538 -23
- package/packages/server/dist/api/ws.js +90 -9
- package/packages/server/dist/control/launcher.js +50 -11
- package/packages/server/dist/control/messaging.js +3 -2
- package/packages/server/dist/control/missionQuiesce.js +66 -0
- package/packages/server/dist/health/deriveAlerts.js +9 -2
- package/packages/server/dist/hooks/hookIngest.js +69 -10
- package/packages/server/dist/index.js +44 -4
- package/packages/server/dist/loops/loopCommand.js +56 -0
- package/packages/server/dist/loops/loopNoop.js +38 -0
- package/packages/server/dist/loops/loopScheduler.js +58 -0
- package/packages/server/dist/loops/loopStore.js +118 -0
- package/packages/server/dist/loops/monitors.js +38 -0
- package/packages/server/dist/messages/attachmentStore.js +92 -0
- package/packages/server/dist/messages/messagesStore.js +110 -33
- package/packages/server/dist/missions/missionsStore.js +9 -0
- package/packages/server/dist/missions/reopenOnWork.js +20 -0
- package/packages/server/dist/plans/planReconcile.js +114 -0
- package/packages/server/dist/plans/plansStore.js +46 -3
- package/packages/server/dist/reviews/reviewDiff.js +47 -0
- package/packages/server/dist/roster/missionReplay.js +82 -0
- package/packages/server/dist/roster/replyAsk.js +62 -0
- package/packages/server/dist/roster/rosterBuilder.js +78 -147
- package/packages/server/dist/roster/workerIdentity.js +923 -0
- package/packages/server/dist/skills/skillDiscovery.js +28 -4
- package/packages/server/dist/terminals/claudeStreamClient.js +90 -0
- package/packages/server/dist/terminals/codexAppServerClient.js +195 -0
- package/packages/server/dist/terminals/providerDetection.js +27 -0
- package/packages/server/dist/terminals/terminalCapability.js +45 -0
- package/packages/server/dist/terminals/terminalFeatures.js +11 -0
- package/packages/server/dist/terminals/terminalObservability.js +21 -0
- package/packages/server/dist/terminals/terminalRuntime.js +125 -0
- package/packages/server/dist/terminals/terminalStream.js +30 -0
- package/packages/server/dist/transcripts/transcriptReader.js +345 -0
- package/packages/server/dist/watch/jobsWatcher.js +55 -24
- package/packages/web/dist/assets/index-DWjqiitn.js +17 -0
- package/packages/web/dist/assets/index-rd4RnLqj.css +2 -0
- package/packages/web/dist/index.html +2 -2
- package/templates/agents/hive-orchestrator.md +1 -0
- package/packages/web/dist/assets/index-Bzle5Xla.css +0 -2
- 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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
+
}
|