@staix/agent-hub 0.7.11 → 0.8.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 +5 -1
- package/README.md +2 -2
- package/docs/events.md +37 -0
- package/docs/operations.md +85 -9
- package/docs/quickstart.md +1 -1
- package/docs/security.md +2 -0
- package/docs/smoke.md +12 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +82 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +16 -4
- package/src/adapters/acp.ts +2 -2
- package/src/adapters/codex-appserver.ts +43 -3
- package/src/cli/main.ts +91 -0
- package/src/hub/board.ts +29 -9
- package/src/hub/budget.ts +3 -0
- package/src/hub/daemon.ts +125 -13
- package/src/hub/events.ts +91 -0
- package/src/hub/hub-tools.ts +15 -3
- package/src/hub/report.ts +68 -0
- package/src/hub/snapshots.ts +191 -0
- package/src/hub/tasks.ts +119 -28
- package/templates/AGENTS.block.md +1 -1
- package/templates/config.json +2 -1
package/src/cli/main.ts
CHANGED
|
@@ -28,6 +28,18 @@ import { ensureMlx, inspectMlx, stopMlx } from "../models/mlx.ts";
|
|
|
28
28
|
import { setupOllamaModel } from "./models-setup.ts";
|
|
29
29
|
|
|
30
30
|
import { backendLine, peerLine, type BackendRow, type PeerRow } from "./status-lines.ts";
|
|
31
|
+
import { parseSince, readEvents } from "../hub/events.ts";
|
|
32
|
+
import { formatReport, summarize } from "../hub/report.ts";
|
|
33
|
+
import { hasTree, planUndo, repoOf, restore, Turns } from "../hub/snapshots.ts";
|
|
34
|
+
|
|
35
|
+
/** `--since 7d|24h|<iso>` for export and report; everything when absent. */
|
|
36
|
+
const since = (): number => {
|
|
37
|
+
const at = args.indexOf("--since");
|
|
38
|
+
if (at < 0) return 0;
|
|
39
|
+
const t = parseSince(args[at + 1] ?? "");
|
|
40
|
+
if (t === undefined) fail("--since takes 7d, 24h, 90m or an ISO date");
|
|
41
|
+
return t;
|
|
42
|
+
};
|
|
31
43
|
|
|
32
44
|
const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one project directory
|
|
33
45
|
|
|
@@ -72,6 +84,11 @@ const USAGE = `agent-hub ${VERSION}: Claude Code, Codex and Kimi as peers in one
|
|
|
72
84
|
ahub queue show <delivery-id> inspect one delivery and revision
|
|
73
85
|
ahub queue resolve <delivery-id> --action completed|retry|discard --reason <text>
|
|
74
86
|
ahub status | logs [-f] | doctor | kill
|
|
87
|
+
ahub export [--since 7d|<iso>] structured events (events.jsonl) as JSON lines; never message bodies
|
|
88
|
+
ahub report [--since 7d|<iso>] [--json] turns, tokens, messages, overlaps and task events per period
|
|
89
|
+
ahub turns [peer] [--limit N] recent turns and the files each changed (a git work tree only)
|
|
90
|
+
ahub undo <turn> [--yes] [--context] put back the files a turn changed; refuses files changed since.
|
|
91
|
+
Without --yes it only lists them; --context also drops a Codex turn from its conversation
|
|
75
92
|
ahub doctor --orphans [--kill] list registrations whose project root is gone; --kill stops their
|
|
76
93
|
daemons (SIGTERM, then SIGKILL) only after the process identity checks out`;
|
|
77
94
|
|
|
@@ -279,6 +296,22 @@ async function taskOp(op: string, a: Record<string, unknown>): Promise<string> {
|
|
|
279
296
|
return res.text;
|
|
280
297
|
}
|
|
281
298
|
|
|
299
|
+
/** Reads hub.db's turn records; `none` when the hub never kept one (snapshots off, no git work tree, an older hub). */
|
|
300
|
+
function turnRecords<T>(read: (turns: Turns) => T, none: T): T {
|
|
301
|
+
const db = join(stateDir, "hub.db");
|
|
302
|
+
if (!existsSync(db)) return none;
|
|
303
|
+
let turns: Turns | undefined;
|
|
304
|
+
try {
|
|
305
|
+
turns = new Turns(db, true);
|
|
306
|
+
return read(turns);
|
|
307
|
+
} catch (error) {
|
|
308
|
+
if (/no such table/.test((error as Error).message)) return none;
|
|
309
|
+
throw error;
|
|
310
|
+
} finally {
|
|
311
|
+
turns?.close();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
282
315
|
/** `--flag value` pairs pulled out of an argument list; the rest keeps its order. */
|
|
283
316
|
function takeFlags(argv: string[], single: string[], repeated: string[]) {
|
|
284
317
|
const one: Record<string, string> = {};
|
|
@@ -716,6 +749,64 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
716
749
|
},
|
|
717
750
|
|
|
718
751
|
logs: () => exec("tail", [args.includes("-f") ? "-f" : "-n100", join(stateDir, "hub.log")]),
|
|
752
|
+
export: () => {
|
|
753
|
+
for (const e of readEvents(join(stateDir, "events.jsonl"), since())) console.log(JSON.stringify(e));
|
|
754
|
+
},
|
|
755
|
+
report: () => {
|
|
756
|
+
const r = summarize(readEvents(join(stateDir, "events.jsonl"), since()));
|
|
757
|
+
console.log(args.includes("--json") ? JSON.stringify(r, null, 2) : formatReport(r).join("\n"));
|
|
758
|
+
},
|
|
759
|
+
turns: () => {
|
|
760
|
+
const { one, rest } = takeFlags(args, ["--limit"], []);
|
|
761
|
+
const rows = turnRecords((t) => t.list(rest[0], Number(one["--limit"]) || 20), []);
|
|
762
|
+
if (!rows.length) return console.log("no turns recorded (they need a git work tree and snapshots.enabled)");
|
|
763
|
+
for (const r of rows) {
|
|
764
|
+
const shown = r.changed.slice(0, 5).join(", ") + (r.changed.length > 5 ? ", ..." : "");
|
|
765
|
+
const files = r.changed.length ? `: ${shown}` : "";
|
|
766
|
+
let status = "";
|
|
767
|
+
if (!r.ended) status = " (running)";
|
|
768
|
+
else if (!r.end_tree) status = " (no end snapshot)";
|
|
769
|
+
console.log(`${r.id} ${new Date(r.started).toLocaleString()}${status} ${r.changed.length} files${files}`);
|
|
770
|
+
}
|
|
771
|
+
},
|
|
772
|
+
undo: async () => {
|
|
773
|
+
const id = args.find((a) => !a.startsWith("--")) ?? fail("usage: ahub undo <turn> [--yes] [--context]");
|
|
774
|
+
const turn = turnRecords((t) => t.get(id), undefined) ?? fail(`no turn ${id} recorded (ahub turns lists them)`);
|
|
775
|
+
if (!turn.ended) fail(`turn ${id} is still running`);
|
|
776
|
+
const { start_tree: startTree, end_tree: endTree } = turn;
|
|
777
|
+
if (!startTree || !endTree) fail(`turn ${id} has no ${startTree ? "end" : "start"} snapshot: the hub stopped during it, or a snapshot failed (see hub.log)`);
|
|
778
|
+
const repo = repoOf(cwd) ?? fail(`${cwd} is not in a git work tree`);
|
|
779
|
+
if (!hasTree(repo.top, startTree) || !hasTree(repo.top, endTree)) fail(`turn ${id}'s snapshots are gone from the git object store (git gc prunes them after two weeks)`);
|
|
780
|
+
// Another peer's turn that overlapped this one in time has its changes in this turn's diff as well.
|
|
781
|
+
const keep = Math.max(1, Number(projectConfig().snapshots.keep) || 20);
|
|
782
|
+
const overlapping = turnRecords((t) => t.overlapping(turn, keep), { paths: [] as string[], unknown: [] as string[] });
|
|
783
|
+
if (overlapping.unknown.length) fail(`refusing to undo ${id}: these turns of other peers ran at the same time and their changes are not known (still running, cut short, or not snapshotted): ${overlapping.unknown.join(", ")}`);
|
|
784
|
+
let reverted = false;
|
|
785
|
+
const plan = () => {
|
|
786
|
+
const p = planUndo(repo, { start_tree: startTree, end_tree: endTree, changed: turn.changed }, overlapping.paths);
|
|
787
|
+
if (p.undone) return p;
|
|
788
|
+
const why = [
|
|
789
|
+
p.changedSince.length ? `these files changed again after it ended, and restoring them would lose that work:\n ${p.changedSince.join("\n ")}` : "",
|
|
790
|
+
p.concurrent.length ? `these files were also changed by another peer's turn running at the same time, so the change may be theirs:\n ${p.concurrent.join("\n ")}` : "",
|
|
791
|
+
].filter(Boolean);
|
|
792
|
+
if (why.length) fail(`refusing to undo ${id}: ${why.join("\n")}${reverted ? "\nCodex's conversation was already reverted; restore the files later with ahub undo without --context" : ""}`);
|
|
793
|
+
return p;
|
|
794
|
+
};
|
|
795
|
+
const first = plan();
|
|
796
|
+
if (first.undone) return console.log(`turn ${id} is already undone: its files are as they were before it started`);
|
|
797
|
+
console.log(first.restore.length ? `turn ${id} changed:\n ${first.restore.join("\n ")}` : `turn ${id} changed no files`);
|
|
798
|
+
console.log("(every change made in the project during the turn counts as its own, including any by Claude or by you)");
|
|
799
|
+
if (!args.includes("--yes")) return console.log("nothing was changed; add --yes to restore these files");
|
|
800
|
+
if (args.includes("--context")) {
|
|
801
|
+
console.log(await taskOp("turn_revert", { turn: id }));
|
|
802
|
+
reverted = true;
|
|
803
|
+
}
|
|
804
|
+
// A peer may have written meanwhile (the revert alone can take seconds): plan again right before restoring.
|
|
805
|
+
const second = plan();
|
|
806
|
+
if (second.restore.join("\0") !== first.restore.join("\0")) fail(`the project changed while undoing; nothing was restored, run ahub undo again${reverted ? " without --context (Codex's conversation was already reverted)" : ""}`);
|
|
807
|
+
restore(repo, startTree, second.restore);
|
|
808
|
+
if (second.restore.length) console.log(`restored to their state before ${id}`);
|
|
809
|
+
},
|
|
719
810
|
|
|
720
811
|
kill: async () => {
|
|
721
812
|
const registry = new Registry();
|
package/src/hub/board.ts
CHANGED
|
@@ -11,6 +11,10 @@ export interface TaskRefs {
|
|
|
11
11
|
commit?: string;
|
|
12
12
|
paths?: string[];
|
|
13
13
|
}
|
|
14
|
+
/** What an owner says it will change, before it starts (issue #31). Each list holds short free-text items. */
|
|
15
|
+
export const PLAN_KEYS = ["paths", "symbols", "signatures", "insertion_points"] as const;
|
|
16
|
+
export type TaskPlan = Partial<Record<(typeof PLAN_KEYS)[number], string[]>>;
|
|
17
|
+
|
|
14
18
|
export interface HistoryEntry {
|
|
15
19
|
at: number;
|
|
16
20
|
by: PeerId;
|
|
@@ -26,6 +30,8 @@ export interface Task {
|
|
|
26
30
|
reviewer: PeerId | null;
|
|
27
31
|
state: TaskState;
|
|
28
32
|
refs: TaskRefs;
|
|
33
|
+
/** Absent only on tasks built outside the board (tests); the board always returns one, `{}` when none was given. */
|
|
34
|
+
plan?: TaskPlan;
|
|
29
35
|
signals: string[];
|
|
30
36
|
/** consecutive changes_requested verdicts */
|
|
31
37
|
rejections: number;
|
|
@@ -43,11 +49,16 @@ const MOVES: Record<TaskState, TaskState[]> = {
|
|
|
43
49
|
approved: [],
|
|
44
50
|
};
|
|
45
51
|
|
|
46
|
-
const JSON_COLS = ["refs", "signals", "history"] as const;
|
|
52
|
+
const JSON_COLS = ["refs", "plan", "signals", "history"] as const;
|
|
47
53
|
|
|
48
54
|
/** Task board in `.agenthub/state/hub.db`. It outlives the hub process: `ahub kill` leaves the file. */
|
|
49
55
|
export class Board {
|
|
50
56
|
private readonly db: Database;
|
|
57
|
+
/** Every recorded change, as it lands (telemetry, issue #40). */
|
|
58
|
+
onChange?: (task: Task, entry: HistoryEntry) => void;
|
|
59
|
+
private changed(task: Task, entry: HistoryEntry): void {
|
|
60
|
+
try { this.onChange?.(task, entry); } catch { /* never throw after a board write */ }
|
|
61
|
+
}
|
|
51
62
|
|
|
52
63
|
constructor(path: string) {
|
|
53
64
|
this.db = new Database(path, { create: true });
|
|
@@ -56,15 +67,21 @@ export class Board {
|
|
|
56
67
|
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '', class TEXT NOT NULL,
|
|
57
68
|
owner TEXT, reviewer TEXT, state TEXT NOT NULL, refs TEXT NOT NULL DEFAULT '{}', signals TEXT NOT NULL DEFAULT '[]',
|
|
58
69
|
rejections INTEGER NOT NULL DEFAULT 0, history TEXT NOT NULL DEFAULT '[]', created INTEGER NOT NULL, updated INTEGER NOT NULL)`);
|
|
70
|
+
// Boards from before issue #31 have no plan column.
|
|
71
|
+
if (!(this.db.query("PRAGMA table_info(tasks)").all() as { name: string }[]).some((c) => c.name === "plan")) {
|
|
72
|
+
this.db.run("ALTER TABLE tasks ADD COLUMN plan TEXT NOT NULL DEFAULT '{}'");
|
|
73
|
+
}
|
|
59
74
|
}
|
|
60
75
|
|
|
61
|
-
propose(by: PeerId, t: { title: string; detail?: string; class: TaskClass; refs?: TaskRefs; signals?: string[] }): Task {
|
|
76
|
+
propose(by: PeerId, t: { title: string; detail?: string; class: TaskClass; refs?: TaskRefs; plan?: TaskPlan; signals?: string[] }): Task {
|
|
62
77
|
const now = Date.now();
|
|
63
78
|
const history: HistoryEntry[] = [{ at: now, by, event: "proposed" }];
|
|
64
79
|
const { lastInsertRowid } = this.db
|
|
65
|
-
.query("INSERT INTO tasks (title, detail, class, state, refs, signals, history, created, updated) VALUES (?, ?, ?, 'proposed', ?, ?, ?, ?, ?)")
|
|
66
|
-
.run(t.title, t.detail ?? "", t.class, JSON.stringify(t.refs ?? {}), JSON.stringify(t.signals ?? []), JSON.stringify(history), now, now);
|
|
67
|
-
|
|
80
|
+
.query("INSERT INTO tasks (title, detail, class, state, refs, plan, signals, history, created, updated) VALUES (?, ?, ?, 'proposed', ?, ?, ?, ?, ?, ?)")
|
|
81
|
+
.run(t.title, t.detail ?? "", t.class, JSON.stringify(t.refs ?? {}), JSON.stringify(t.plan ?? {}), JSON.stringify(t.signals ?? []), JSON.stringify(history), now, now);
|
|
82
|
+
const task = this.get(Number(lastInsertRowid))!;
|
|
83
|
+
this.changed(task, history[0]!);
|
|
84
|
+
return task;
|
|
68
85
|
}
|
|
69
86
|
|
|
70
87
|
get(id: number): Task | undefined {
|
|
@@ -84,7 +101,8 @@ export class Board {
|
|
|
84
101
|
}
|
|
85
102
|
|
|
86
103
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
87
|
-
|
|
104
|
+
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
105
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "reviewer" | "refs" | "plan" | "rejections">>, note?: string): Task {
|
|
88
106
|
const task = this.get(id);
|
|
89
107
|
if (!task) throw new Error(`no task #${id}`);
|
|
90
108
|
if (patch.state && patch.state !== task.state && !MOVES[task.state].includes(patch.state)) {
|
|
@@ -93,9 +111,11 @@ export class Board {
|
|
|
93
111
|
const next = { ...task, ...patch, refs: { ...task.refs, ...patch.refs } };
|
|
94
112
|
const history = [...task.history, { at: Date.now(), by, event, ...(note ? { note } : {}) }];
|
|
95
113
|
this.db
|
|
96
|
-
.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, refs = ?, rejections = ?, history = ?, updated = ? WHERE id = ?")
|
|
97
|
-
.run(next.state, next.owner, next.reviewer, JSON.stringify(next.refs), next.rejections, JSON.stringify(history), Date.now(), id);
|
|
98
|
-
|
|
114
|
+
.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, refs = ?, plan = ?, rejections = ?, history = ?, updated = ? WHERE id = ?")
|
|
115
|
+
.run(next.state, next.owner, next.reviewer, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(history), Date.now(), id);
|
|
116
|
+
const updated = this.get(id)!;
|
|
117
|
+
this.changed(updated, history.at(-1)!);
|
|
118
|
+
return updated;
|
|
99
119
|
}
|
|
100
120
|
|
|
101
121
|
close(): void {
|
package/src/hub/budget.ts
CHANGED
|
@@ -55,6 +55,8 @@ export interface BudgetHooks {
|
|
|
55
55
|
/** Move the peer's open work. */
|
|
56
56
|
handoff(peer: PeerId, context: string | undefined): Promise<Moved[]>;
|
|
57
57
|
resumed(record: PauseRecord): void;
|
|
58
|
+
/** Every reading as it arrives, with the time it was measured (telemetry, issue #40). */
|
|
59
|
+
reading?(peer: PeerId, windows: UsageWindow[], hard: boolean, at: number): void;
|
|
58
60
|
notify(line: string): void;
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -144,6 +146,7 @@ export class Budget {
|
|
|
144
146
|
/** `at`: when the numbers were measured, for sources that arrive through a file (a leftover file is not a fresh reading). */
|
|
145
147
|
report(peer: PeerId, windows: UsageWindow[], hard = false, at = this.now()): void {
|
|
146
148
|
if (EXEMPT.has(peer) || this.closed) return;
|
|
149
|
+
try { this.hooks.reading?.(peer, windows, hard, at); } catch { /* telemetry never costs a reading */ }
|
|
147
150
|
const mine = this.readings.get(peer) ?? new Map<string, Reading>();
|
|
148
151
|
this.readings.set(peer, mine);
|
|
149
152
|
for (const w of windows) mine.set(w.id, { ...w, used: Math.max(0, Math.min(1, w.used)), at });
|
package/src/hub/daemon.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { spawn } from "node:child_process";
|
|
|
6
6
|
import { childEnv } from "./child-process.ts";
|
|
7
7
|
import { runCheck } from "./checks.ts";
|
|
8
8
|
import { stripUntrusted } from "./config-trust.ts";
|
|
9
|
+
import { eventLog, tokenDeltas } from "./events.ts";
|
|
9
10
|
import type { ServerWebSocket } from "bun";
|
|
10
11
|
import { AcpPeer, type PermissionRequest } from "../adapters/acp.ts";
|
|
11
12
|
import { CodexPeer } from "../adapters/codex-appserver.ts";
|
|
@@ -40,6 +41,7 @@ import { BasePeer, DEFAULT_WATCHDOG_MS, type PeerAdapter } from "./peers.ts";
|
|
|
40
41
|
import { MemoryClient, workerUrl } from "../memory/client.ts";
|
|
41
42
|
import { VERSION } from "../version.ts";
|
|
42
43
|
import { projectChain, recallFor } from "../memory/recall.ts";
|
|
44
|
+
import { changedPaths, repoOf, snapshot, Turns } from "./snapshots.ts";
|
|
43
45
|
import { archiveRestartSnapshot, readRestartSnapshot, removeRestartSnapshot, restartPath, writeRestartSnapshot, type RecoveryPhase, type RestartPeerSnapshot, type RestartSnapshot } from "./restart.ts";
|
|
44
46
|
|
|
45
47
|
export interface HubConfig {
|
|
@@ -63,6 +65,8 @@ export interface HubConfig {
|
|
|
63
65
|
tasks: { release_after_min: number };
|
|
64
66
|
/** A command per task class run when the owner marks the task done, and its timeout (issue #7). */
|
|
65
67
|
checks: { timeout_s: number; [cls: string]: string | number };
|
|
68
|
+
/** A git tree at each turn boundary for `ahub turns` and `ahub undo`, the last `keep` per peer (issue #33). */
|
|
69
|
+
snapshots: { enabled: boolean; keep: number };
|
|
66
70
|
/** Machine-local fields a config file set but git could not vouch for, and why (issue #17). */
|
|
67
71
|
ignored?: string[];
|
|
68
72
|
}
|
|
@@ -85,6 +89,8 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
85
89
|
approvals: { timeout_s: 120, notify: false },
|
|
86
90
|
tasks: { release_after_min: 30 },
|
|
87
91
|
checks: { timeout_s: 600 },
|
|
92
|
+
// Off here like approvals.notify, so tests (whose cwd is this repository) write no objects; a project's config turns it on.
|
|
93
|
+
snapshots: { enabled: false, keep: 20 },
|
|
88
94
|
};
|
|
89
95
|
|
|
90
96
|
export { stateDirFor };
|
|
@@ -95,7 +101,7 @@ const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
|
95
101
|
|
|
96
102
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
97
103
|
const CONFIG_FILES = ["config.json", "config.local.json"] as const;
|
|
98
|
-
const CONFIG_BLOCKS = ["memory", "roles", "budget", "inference", "omniroute", "local", "pi", "approvals", "tasks", "checks", "mlx"];
|
|
104
|
+
const CONFIG_BLOCKS = ["memory", "roles", "budget", "inference", "omniroute", "local", "pi", "approvals", "tasks", "checks", "snapshots", "mlx"];
|
|
99
105
|
|
|
100
106
|
export function loadConfig(cwd: string): HubConfig {
|
|
101
107
|
const ignored: string[] = [];
|
|
@@ -138,6 +144,7 @@ export function loadConfig(cwd: string): HubConfig {
|
|
|
138
144
|
},
|
|
139
145
|
tasks: { ...DEFAULT_CONFIG.tasks, ...file.tasks },
|
|
140
146
|
checks: { ...DEFAULT_CONFIG.checks, ...file.checks },
|
|
147
|
+
snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
|
|
141
148
|
mlx,
|
|
142
149
|
...(ignored.length ? { ignored } : {}),
|
|
143
150
|
};
|
|
@@ -224,6 +231,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
224
231
|
const logFile = join(opts.stateDir, "hub.log");
|
|
225
232
|
// The state dir can vanish under a running hub (issue #56): a log line must never take a handler down with it.
|
|
226
233
|
const log = (line: string) => { try { appendFileSync(logFile, `${new Date().toISOString()} ${line}\n`); } catch { /* the state dir is gone; the watchdog is stopping the hub */ } };
|
|
234
|
+
const event = eventLog(join(opts.stateDir, "events.jsonl"));
|
|
227
235
|
|
|
228
236
|
// Any local web page can open a WebSocket to a loopback port, so the control link needs a secret.
|
|
229
237
|
// The file is written only after the port is bound: a second daemon that loses the bind must not clobber it.
|
|
@@ -359,6 +367,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
359
367
|
project: chain.at(-1)!,
|
|
360
368
|
...(config.memory.enabled ? { memory, briefs: new Briefs(memory, chain.at(-1)!, config.memory.brief_items) } : {}),
|
|
361
369
|
notify,
|
|
370
|
+
recordOverlap: (task, owner, others) => event({ type: "overlap", task, owner, others }),
|
|
362
371
|
...(checksAllowed ? { check: (cls: string) => checkCommands[cls], runCheck: async (command: string) => (checksClosed ? interrupted : runCheck(command, opts.cwd, checkTimeoutS * 1000, runningChecks)) } : {}),
|
|
363
372
|
// What a peer learned reaches the peers working now, on their next delivery, instead of at their next session (#68).
|
|
364
373
|
// Fail-open: the note is already saved, so a bus that cannot persist costs the sharing, not the tool call.
|
|
@@ -378,6 +387,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
378
387
|
},
|
|
379
388
|
triage: { classify: (title, detail) => inference?.triage(title, detail) ?? Promise.resolve(undefined), onCampus: () => onCampus() },
|
|
380
389
|
});
|
|
390
|
+
board.onChange = (t, h) => event({ type: "task", id: t.id, event: h.event, by: h.by, state: t.state, owner: t.owner, reviewer: t.reviewer, class: t.class, pii: tasks.isPii(t) });
|
|
381
391
|
// ---- budget relay -------------------------------------------------------------------------------------------
|
|
382
392
|
const checkpointWaits = new Map<PeerId, (summary: string | undefined) => void>();
|
|
383
393
|
const PLATFORM: Record<PeerId, string> = { claude: "claude", codex: "codex", kimi: "kimi" };
|
|
@@ -414,6 +424,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
414
424
|
// Somebody other than the paused peer has to be there, or the handoff would only leave its tasks without an owner.
|
|
415
425
|
canHandOff: (peer) => [...bus.peers.keys()].some((id) => id !== peer && ["idle", "busy"].includes(bus.stateOf(id))),
|
|
416
426
|
handoff: (peer, context) => tasks.reassignForPause(peer, context),
|
|
427
|
+
// A reading that arrived through a file carries the file's time; a stale one must not look fresh in the export.
|
|
428
|
+
reading: (peer, windows, hard, at) => event({ type: "quota", peer, windows: windows.map((w) => ({ id: w.id, used: w.used, ...(w.resetsAt ? { resetsAt: w.resetsAt } : {}) })), hard, ...(Math.abs(Date.now() - at) > 1000 ? { measuredAt: new Date(at).toISOString() } : {}) }),
|
|
417
429
|
resumed: (record) => {
|
|
418
430
|
if (record.peer === "kimi") kimiTokens.length = 0; // a new window: the old counts would pause it again at once
|
|
419
431
|
const moved = record.moved.length ? `While you were paused these moved: ${record.moved.map((m) => `${m.title} (${m.role} -> ${m.to ?? "nobody"})`).join("; ")}. They stay where they are; ask the user if you should take one back.` : "Nothing was moved while you were paused.";
|
|
@@ -424,14 +436,47 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
424
436
|
budget.setRecoveryHold(recoveryActive());
|
|
425
437
|
const kimiTokens: { at: number; n: number }[] = [];
|
|
426
438
|
startupCleanup.push(() => budget.close());
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
439
|
+
// Turns and their tokens for telemetry (issue #40). Turn ids carry the hub run, so they stay unique across restarts.
|
|
440
|
+
const runId = Date.now().toString(36);
|
|
441
|
+
let turnSeq = 0;
|
|
442
|
+
const turns = new Map<PeerId, { id: string; start: number; tokens: number; tree?: string; snapshotMs?: number; private?: boolean }>();
|
|
443
|
+
// Per-turn snapshots (issue #33): a git tree at both turn boundaries, recorded in hub.db for `ahub turns` and `ahub undo`.
|
|
444
|
+
const repo = config.snapshots.enabled ? repoOf(opts.cwd) : undefined;
|
|
445
|
+
if (config.snapshots.enabled && !repo) log("snapshots: the project is not in a git work tree, so turns are not recorded");
|
|
446
|
+
const turnLog = repo ? new Turns(join(opts.stateDir, "hub.db")) : undefined;
|
|
447
|
+
if (turnLog) startupCleanup.push(() => turnLog.close());
|
|
448
|
+
/** Whether a peer has a PII task open in any state: assigned and not yet accepted counts. */
|
|
449
|
+
const holdsPii = (peer: PeerId) => board.list().some((t) => t.owner === peer && t.state !== "approved" && t.state !== "in_review" && tasks.isPii(t));
|
|
450
|
+
/**
|
|
451
|
+
* A snapshot that fails costs the undo record of that turn and nothing else. The peer's prompt waits for it (it
|
|
452
|
+
* runs inside the state change): three git calls at a turn's start and four at its end, each with the 10 s timeout
|
|
453
|
+
* in snapshots.ts.
|
|
454
|
+
*/
|
|
455
|
+
const snap = (what: string): { tree?: string; ms: number } => {
|
|
456
|
+
const t0 = performance.now();
|
|
457
|
+
let tree: string | undefined;
|
|
458
|
+
try {
|
|
459
|
+
tree = repo && snapshot(repo);
|
|
460
|
+
if (repo && !tree) log(`snapshot failed at ${what}`);
|
|
461
|
+
} catch (error) {
|
|
462
|
+
log(`snapshot failed at ${what}: ${(error as Error).message}`);
|
|
463
|
+
}
|
|
464
|
+
return { tree, ms: Math.round(performance.now() - t0) };
|
|
465
|
+
};
|
|
466
|
+
const delta = tokenDeltas();
|
|
467
|
+
/** Tokens a peer used, as increments: Kimi reports a session total (turned into increments below), Codex increments. */
|
|
468
|
+
const addTokens = (peer: PeerId, n: number): number => {
|
|
469
|
+
if (n > 0) {
|
|
470
|
+
event({ type: "tokens", peer, n });
|
|
471
|
+
const turn = turns.get(peer);
|
|
472
|
+
if (turn) turn.tokens += n;
|
|
473
|
+
}
|
|
474
|
+
return n;
|
|
475
|
+
};
|
|
476
|
+
const onKimiTokens = (total: number, session: string) => {
|
|
477
|
+
const n = addTokens("kimi", delta("kimi", total, session));
|
|
478
|
+
if (!config.budget.kimi_tokens_5h || !n) return;
|
|
431
479
|
const now = Date.now();
|
|
432
|
-
const n = total >= kimiSessionTotal ? total - kimiSessionTotal : total; // a smaller total means a new session
|
|
433
|
-
kimiSessionTotal = total;
|
|
434
|
-
if (!n) return;
|
|
435
480
|
kimiTokens.push({ at: now, n });
|
|
436
481
|
while (kimiTokens.length && now - kimiTokens[0]!.at > 5 * 3_600_000) kimiTokens.shift();
|
|
437
482
|
const used = kimiTokens.reduce((s, t) => s + t.n, 0) / config.budget.kimi_tokens_5h;
|
|
@@ -478,6 +523,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
478
523
|
// Inside a PII turn the worker's words may carry the PII whatever they are attached to: a note would go to
|
|
479
524
|
// claude-mem (a cloud observer) and a new task could be routed to a cloud peer without matching any pattern.
|
|
480
525
|
if (piiTurn && (op === "hub_remember" || op === "hub_task_propose")) throw new Error(`${op} is not available while working on a PII task: its text must not leave this machine`);
|
|
526
|
+
// The same words attached to an ordinary task would reach its reviewer, its owner, overlapping owners (a plan, the
|
|
527
|
+
// completed-change notice) and claude-mem. A turn that holds a PII task acts on ordinary tasks in a turn of its own.
|
|
528
|
+
if (piiTurn && (op === "hub_task_done" || op === "hub_review" || (op === "hub_task_accept" && a.plan != null))) {
|
|
529
|
+
const target = board.get(Number(a.id));
|
|
530
|
+
if (target && !tasks.isPii(target)) throw new Error(`${op} on task #${target.id} is not available while working on a PII task: its text would reach other peers; do it in a turn without the PII task`);
|
|
531
|
+
}
|
|
481
532
|
// Lists are redacted for everyone but the on-prem worker, and only when it calls from inside this process: over the
|
|
482
533
|
// control WS anyone holding the token can claim to be "local". A board on a shared screen is a leak too, so the
|
|
483
534
|
// console reads a PII task's text deliberately, with `ahub task show <id>`.
|
|
@@ -489,8 +540,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
489
540
|
const overlap = tasks.overlaps(t, t.owner === by);
|
|
490
541
|
return overlap ? `${line(t)}\n${overlap}` : line(t);
|
|
491
542
|
}
|
|
492
|
-
case "hub_task_accept":
|
|
493
|
-
|
|
543
|
+
case "hub_task_accept": {
|
|
544
|
+
const t = tasks.accept(by, a.id, a.plan);
|
|
545
|
+
const overlap = a.plan == null ? "" : tasks.overlaps(t);
|
|
546
|
+
return overlap ? `${line(t)}\n${overlap}` : line(t);
|
|
547
|
+
}
|
|
494
548
|
case "hub_task_decline":
|
|
495
549
|
return line(await tasks.decline(by, a.id, a.reason));
|
|
496
550
|
case "hub_task_done": {
|
|
@@ -521,6 +575,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
521
575
|
return line(await tasks.escalate(USER, a.id));
|
|
522
576
|
case "route_explain":
|
|
523
577
|
return tasks.explain(a.id !== undefined ? Number(a.id) : { title: String(a.title ?? ""), class: a.class as TaskClass }).join("\n");
|
|
578
|
+
case "turn_revert": {
|
|
579
|
+
// `ahub undo --context` (issue #33): the conversation half of an undo; the CLI restores the files itself.
|
|
580
|
+
const turn = turnLog?.get(String(a.turn));
|
|
581
|
+
if (!turn) throw new Error(`no turn ${a.turn} recorded`);
|
|
582
|
+
if (turn.peer !== "codex" || !turn.native) throw new Error(`turn ${turn.id} has no Codex conversation turn to revert`);
|
|
583
|
+
if (turnLog!.latest("codex")?.id !== turn.id) throw new Error(`turn ${turn.id} is not Codex's latest turn; reverting it would drop the later ones too`);
|
|
584
|
+
const codex = bus.peers.get("codex");
|
|
585
|
+
if (!(codex instanceof CodexPeer)) throw new Error("codex is not attached");
|
|
586
|
+
// Nothing may reach Codex while its history is rewritten: a turn started meanwhile would go with the reverted ones.
|
|
587
|
+
const held = bus.stateOf("codex") !== "paused";
|
|
588
|
+
if (held) bus.pause("codex");
|
|
589
|
+
try {
|
|
590
|
+
await codex.revert(turn.native);
|
|
591
|
+
} finally {
|
|
592
|
+
if (held && !manualPaused.has("codex") && !budget.record("codex")) bus.resume("codex");
|
|
593
|
+
}
|
|
594
|
+
log(`turn_revert ${turn.id}: Codex conversation reverted to before ${turn.native}`);
|
|
595
|
+
return `Codex's conversation no longer holds turn ${turn.id}`;
|
|
596
|
+
}
|
|
524
597
|
}
|
|
525
598
|
throw new Error(`unknown task operation ${op}`);
|
|
526
599
|
}
|
|
@@ -672,12 +745,45 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
672
745
|
if (uiEvents.length > 200) uiEvents.shift();
|
|
673
746
|
if (e.t === "state") {
|
|
674
747
|
log(`state ${e.peer} -> ${e.state}`);
|
|
748
|
+
event({ type: "state", peer: e.peer, state: e.state });
|
|
749
|
+
const open = turns.get(e.peer);
|
|
750
|
+
// The adapter's own state: a pause shows a busy peer as paused, and its turn goes on all the same.
|
|
751
|
+
const busy = (bus.peers.get(e.peer)?.state ?? e.state) === "busy";
|
|
752
|
+
if (busy && !open) {
|
|
753
|
+
const id = `${e.peer}#${runId}.${++turnSeq}`;
|
|
754
|
+
// A turn of a peer with a PII task open is not snapshotted: what it writes would stay in git's object store until
|
|
755
|
+
// gc. It is still recorded, without trees, so an overlapping turn's undo knows its changes are unknown.
|
|
756
|
+
const pii = holdsPii(e.peer);
|
|
757
|
+
const start = turnLog && !pii ? snap(`the start of ${id}`) : undefined; // before the peer is handed anything: the tap runs inside setState
|
|
758
|
+
try { turnLog?.begin(id, e.peer, start?.tree); } catch (error) { log(`turn record ${id}: ${(error as Error).message}`); }
|
|
759
|
+
turns.set(e.peer, { id, start: Date.now(), tokens: 0, ...(start ? { tree: start.tree, snapshotMs: start.ms } : {}), ...(pii ? { private: true } : {}) });
|
|
760
|
+
event({ type: "turn_start", peer: e.peer, turn: id });
|
|
761
|
+
} else if (!busy && open) {
|
|
762
|
+
turns.delete(e.peer);
|
|
763
|
+
let files: number | undefined;
|
|
764
|
+
let snapshotMs = open.snapshotMs;
|
|
765
|
+
if (turnLog && (open.private || holdsPii(e.peer))) {
|
|
766
|
+
try { turnLog.end(open.id, undefined, [], Math.max(1, Number(config.snapshots.keep) || 20)); } catch (error) { log(`turn record ${open.id}: ${(error as Error).message}`); }
|
|
767
|
+
} else if (turnLog) {
|
|
768
|
+
const end = snap(`the end of ${open.id}`);
|
|
769
|
+
const changed = open.tree && end.tree ? changedPaths(repo!, open.tree, end.tree) : [];
|
|
770
|
+
if (open.tree && end.tree) files = changed.length; // unknown, not zero, when a snapshot failed
|
|
771
|
+
snapshotMs = (snapshotMs ?? 0) + end.ms;
|
|
772
|
+
try { turnLog.end(open.id, end.tree, changed, Math.max(1, Number(config.snapshots.keep) || 20)); } catch (error) { log(`turn record ${open.id}: ${(error as Error).message}`); }
|
|
773
|
+
}
|
|
774
|
+
event({ type: "turn_end", peer: e.peer, turn: open.id, ms: Date.now() - open.start, ...(open.tokens ? { tokens: open.tokens } : {}), ...(files !== undefined ? { files, snapshotMs } : {}) });
|
|
775
|
+
}
|
|
675
776
|
if (e.state === "offline") offlineSince.set(e.peer, offlineSince.get(e.peer) ?? Date.now());
|
|
676
777
|
else offlineSince.delete(e.peer);
|
|
677
778
|
}
|
|
678
|
-
else if (e.t === "undeliverable"
|
|
679
|
-
|
|
680
|
-
|
|
779
|
+
else if (e.t === "undeliverable" || e.t === "overflow") {
|
|
780
|
+
log(e.t === "undeliverable" ? `UNDELIVERABLE to ${e.peer} after retries: ${e.env.id} from ${e.env.from}` : `OVERFLOW ${e.peer}: dropped ${e.env.id} from ${e.env.from}`);
|
|
781
|
+
event({ type: e.t, id: e.env.id, from: e.env.from, peer: e.peer });
|
|
782
|
+
} else {
|
|
783
|
+
log(`msg ${e.env.from} -> ${e.env.to?.join(",") ?? "*"} ${e.env.priority} hop=${e.env.hop}${e.dropped ? ` NOT DELIVERED(${e.dropped})` : ""}: ${e.env.body.slice(0, 200)}`);
|
|
784
|
+
const env = e.env;
|
|
785
|
+
event({ type: "envelope", id: env.id, from: env.from, ...(env.to ? { to: env.to } : {}), priority: env.priority, hop: env.hop, ...(env.kind ? { kind: env.kind } : {}), ...(env.refs?.task ? { task: env.refs.task } : {}), ...(env.private ? {} : { bytes: Buffer.byteLength(env.body) }), ...(env.private ? { private: true } : {}), ...(e.dropped ? { dropped: e.dropped } : {}) });
|
|
786
|
+
}
|
|
681
787
|
if (!stopping) writeStatus();
|
|
682
788
|
});
|
|
683
789
|
|
|
@@ -839,6 +945,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
839
945
|
}
|
|
840
946
|
if (peer === "codex") {
|
|
841
947
|
const codex = new CodexPeer("codex", {
|
|
948
|
+
onTokens: (added) => void addTokens("codex", added),
|
|
949
|
+
onTurn: (native) => {
|
|
950
|
+
const open = turns.get("codex");
|
|
951
|
+
if (open) turnLog?.native(open.id, native);
|
|
952
|
+
},
|
|
842
953
|
appPort: opts.codexAppPort,
|
|
843
954
|
proxyPort: opts.codexProxyPort,
|
|
844
955
|
bin: config.codex_bin,
|
|
@@ -1425,6 +1536,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1425
1536
|
await piReceipts?.close();
|
|
1426
1537
|
budget.close();
|
|
1427
1538
|
board.close();
|
|
1539
|
+
turnLog?.close();
|
|
1428
1540
|
server.stop(true);
|
|
1429
1541
|
try { bus.closeJournal(); } catch { /* the state dir is gone; the journal went with it */ }
|
|
1430
1542
|
} finally {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/** Bumped whenever a field changes meaning or goes away; new fields and new types do not bump it. */
|
|
4
|
+
export const EVENTS_SCHEMA = 1;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One line of `events.jsonl`, the machine-readable twin of hub.log (issue #40). Never a message body: envelopes carry
|
|
8
|
+
* ids, routing and sizes, tasks carry ids and states, so the file can be exported without leaking what agents wrote.
|
|
9
|
+
*/
|
|
10
|
+
export type HubEvent =
|
|
11
|
+
| { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string }
|
|
12
|
+
| { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
|
|
13
|
+
| { type: "state"; peer: string; state: string }
|
|
14
|
+
| { type: "turn_start"; peer: string; turn: string }
|
|
15
|
+
| { type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number }
|
|
16
|
+
| { type: "tokens"; peer: string; n: number }
|
|
17
|
+
| { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; pii: boolean }
|
|
18
|
+
| { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
|
|
19
|
+
| { type: "quota"; peer: string; windows: { id: string; used: number; resetsAt?: number }[]; hard: boolean; measuredAt?: string };
|
|
20
|
+
|
|
21
|
+
export type StampedEvent = HubEvent & { v: number; at: string };
|
|
22
|
+
|
|
23
|
+
/** Whether the file's last byte is something other than a newline: a crash cut its last line short. */
|
|
24
|
+
function cutShort(file: string): boolean {
|
|
25
|
+
if (!existsSync(file)) return false;
|
|
26
|
+
const fd = openSync(file, "r");
|
|
27
|
+
try {
|
|
28
|
+
const { size } = fstatSync(fd);
|
|
29
|
+
if (!size) return false;
|
|
30
|
+
const last = Buffer.alloc(1);
|
|
31
|
+
readSync(fd, last, 0, 1, size - 1);
|
|
32
|
+
return last[0] !== 0x0a;
|
|
33
|
+
} finally {
|
|
34
|
+
closeSync(fd);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** An appender that never throws: the state dir can vanish under a running hub, and telemetry must not take it down. */
|
|
39
|
+
export function eventLog(file: string): (e: HubEvent) => void {
|
|
40
|
+
let first = true;
|
|
41
|
+
return (e) => {
|
|
42
|
+
try {
|
|
43
|
+
// After a crash the first line goes on a line of its own, so only the cut event is lost.
|
|
44
|
+
const lead = first && cutShort(file) ? "\n" : "";
|
|
45
|
+
first = false;
|
|
46
|
+
appendFileSync(file, `${lead}${JSON.stringify({ v: EVENTS_SCHEMA, at: new Date().toISOString(), ...e })}\n`);
|
|
47
|
+
} catch {
|
|
48
|
+
// the state dir is gone; the watchdog is stopping the hub
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Kimi's running session totals to increments: only what was added since the last update counts; a new session
|
|
55
|
+
* starts from zero whatever its first total, and a smaller total in the same session means it was reset. (Codex
|
|
56
|
+
* differences its thread totals in the adapter, which knows whether a thread was started or resumed.)
|
|
57
|
+
*/
|
|
58
|
+
export function tokenDeltas(): (peer: string, total: number, session: string) => number {
|
|
59
|
+
const last = new Map<string, { session: string; total: number }>();
|
|
60
|
+
return (peer, total, session) => {
|
|
61
|
+
const prev = last.get(peer);
|
|
62
|
+
last.set(peer, { session, total });
|
|
63
|
+
return prev?.session === session && total >= prev.total ? total - prev.total : total;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Events at or after `since` (ms), skipping lines a crash cut short.
|
|
69
|
+
* ponytail: reads the whole file, which is never rotated; rotate by month or index by time when it gets large.
|
|
70
|
+
*/
|
|
71
|
+
export function readEvents(file: string, since = 0): StampedEvent[] {
|
|
72
|
+
if (!existsSync(file)) return [];
|
|
73
|
+
return readFileSync(file, "utf8").split("\n").flatMap((line) => {
|
|
74
|
+
if (!line.trim()) return [];
|
|
75
|
+
try {
|
|
76
|
+
const e = JSON.parse(line) as StampedEvent;
|
|
77
|
+
return Date.parse(e.at) >= since ? [e] : [];
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `7d`, `24h`, `90m` or an ISO date, as epoch ms; undefined when it is neither. */
|
|
85
|
+
export function parseSince(text: string, now = Date.now()): number | undefined {
|
|
86
|
+
const m = /^(\d+)([dhm])$/.exec(text);
|
|
87
|
+
if (m) return now - Number(m[1]) * { d: 86_400_000, h: 3_600_000, m: 60_000 }[m[2] as "d" | "h" | "m"];
|
|
88
|
+
// ISO dates only: Date.parse also takes "7" (a year in 2001), which would silently mean everything.
|
|
89
|
+
const t = /^\d{4}-\d\d-\d\d/.test(text) ? Date.parse(text) : NaN;
|
|
90
|
+
return Number.isNaN(t) ? undefined : t;
|
|
91
|
+
}
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -9,6 +9,18 @@ const refs = {
|
|
|
9
9
|
properties: { branch: str, commit: str, paths: { type: "array", items: str } },
|
|
10
10
|
additionalProperties: false,
|
|
11
11
|
} as const;
|
|
12
|
+
const list = (description: string) => ({ type: "array", items: str, description }) as const;
|
|
13
|
+
const plan = {
|
|
14
|
+
type: "object",
|
|
15
|
+
description: "what you will change, before you start. Owners of open tasks on the same files or symbols see it, and get a notice when your task is done.",
|
|
16
|
+
properties: {
|
|
17
|
+
paths: list("files you will edit or create"),
|
|
18
|
+
symbols: list("functions, types or other names you will change, as they appear in code (e.g. Bus.publish)"),
|
|
19
|
+
signatures: list("new or changed signatures, as you will write them"),
|
|
20
|
+
insertion_points: list("where new code goes (file and the function or line it follows)"),
|
|
21
|
+
},
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
} as const;
|
|
12
24
|
|
|
13
25
|
export interface HubTool {
|
|
14
26
|
name: string;
|
|
@@ -23,8 +35,8 @@ const tool = (name: string, description: string, properties: Record<string, unkn
|
|
|
23
35
|
});
|
|
24
36
|
|
|
25
37
|
export const TASK_TOOLS: HubTool[] = [
|
|
26
|
-
tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, refs, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it" } }, ["title"]),
|
|
27
|
-
tool("hub_task_accept", "Take a task that was assigned to you.", { id }, ["id"]),
|
|
38
|
+
tool("hub_task_propose", "Put a piece of work on the shared task board. The hub assigns an owner by class (routing.toml) unless you name one. Classes: plan, implement, bulk_edit, test, review, summarize, triage. Name the class when you know it; without one the hub tries to pick it. Name yourself as owner to claim work nobody assigned you: it starts in progress. Put the paths the work touches in refs, and when you claim work, a plan; the hub says when they overlap another open task.", { title: str, class: { type: "string", enum: ["plan", "implement", "bulk_edit", "test", "review", "summarize", "triage"] }, detail: str, refs, plan, owner: { type: "string", description: "peer id; yours to claim the work, omit to let the hub route it" } }, ["title"]),
|
|
39
|
+
tool("hub_task_accept", "Take a task that was assigned to you, with your plan for it.", { id, plan }, ["id"]),
|
|
28
40
|
tool("hub_task_decline", "Pass on a task assigned to you; the hub offers it to the next peer.", { id, reason: str }, ["id"]),
|
|
29
41
|
tool("hub_task_done", "Mark your task finished. It goes to its reviewer with your summary and refs. When the project configures a check for its class, the hub runs it first and the result comes as a task message: a failed check keeps the task with you.", { id, summary: { type: "string", description: "what changed, why, and the check you ran with its result" }, refs }, ["id", "summary"]),
|
|
30
42
|
tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] } }),
|
|
@@ -38,7 +50,7 @@ export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
|
|
|
38
50
|
/** Role contracts, by role name. Shown to each peer for the roles `.agenthub/config.json` gives it. */
|
|
39
51
|
export const ROLE_TEXT: Record<string, string> = {
|
|
40
52
|
planner: "planner: break work into tasks with hub_task_propose (one outcome each, the right class, paths in refs) instead of doing everything yourself.",
|
|
41
|
-
implementer: "implementer: accept tasks assigned to you, do them, and finish with hub_task_done (summary: what changed, why, and the check you ran with its result; refs). Decline what you cannot do. Before starting work nobody assigned you, claim it with hub_task_propose naming yourself as owner, with the paths in refs.",
|
|
53
|
+
implementer: "implementer: accept tasks assigned to you, do them, and finish with hub_task_done (summary: what changed, why, and the check you ran with its result; refs). Decline what you cannot do. Before starting work nobody assigned you, claim it with hub_task_propose naming yourself as owner, with the paths in refs. With a claim or an accept, give a plan: the files, symbols and signatures you will change and where new code goes.",
|
|
42
54
|
verifier: "verifier: run the checks a task names and report what passed and what did not in hub_task_done.",
|
|
43
55
|
reviewer: "reviewer: when asked to review, read the change itself, then hub_review with approved or changes_requested and a note that says what to fix.",
|
|
44
56
|
};
|