@staix/agent-hub 0.2.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/.claude-plugin/marketplace.json +12 -0
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +54 -0
- package/package.json +41 -0
- package/plugins/agent-hub/.claude-plugin/plugin.json +11 -0
- package/plugins/agent-hub/.mcp.json +8 -0
- package/plugins/agent-hub/server.js +15832 -0
- package/src/adapters/acp.ts +183 -0
- package/src/adapters/claude-channel.ts +146 -0
- package/src/adapters/codex-appserver.ts +323 -0
- package/src/adapters/local-worker.ts +232 -0
- package/src/cli/init.ts +43 -0
- package/src/cli/launch.ts +66 -0
- package/src/cli/main.js +9 -0
- package/src/cli/main.ts +434 -0
- package/src/cli/setup.ts +79 -0
- package/src/cli/statusline-tee.ts +33 -0
- package/src/hub/ask.ts +163 -0
- package/src/hub/board.ts +110 -0
- package/src/hub/budget.ts +268 -0
- package/src/hub/bus.ts +251 -0
- package/src/hub/control-client.ts +78 -0
- package/src/hub/daemon.ts +658 -0
- package/src/hub/envelope.ts +95 -0
- package/src/hub/hub-tools.ts +50 -0
- package/src/hub/inference.ts +130 -0
- package/src/hub/peers.ts +68 -0
- package/src/hub/ports.ts +29 -0
- package/src/hub/routing.ts +160 -0
- package/src/hub/tasks.ts +293 -0
- package/src/local/deny.ts +36 -0
- package/src/local/sandbox.ts +94 -0
- package/src/local/tools.ts +160 -0
- package/src/memory/brief.ts +59 -0
- package/src/memory/capture.ts +83 -0
- package/src/memory/client.ts +79 -0
- package/src/memory/recall.ts +55 -0
- package/src/omniroute/client.ts +163 -0
- package/src/switchyard/config.ts +45 -0
- package/src/switchyard/sidecar.ts +102 -0
- package/src/version.ts +4 -0
- package/templates/AGENTS.block.md +11 -0
- package/templates/CLAUDE.block.md +10 -0
- package/templates/config.json +25 -0
- package/templates/routing.toml +84 -0
package/src/hub/tasks.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import type { Briefs } from "../memory/brief.ts";
|
|
2
|
+
import type { MemoryClient } from "../memory/client.ts";
|
|
3
|
+
import { CLASSES, type Board, type Task, type TaskClass, type TaskRefs } from "./board.ts";
|
|
4
|
+
import type { Bus } from "./bus.ts";
|
|
5
|
+
import { HUB, newEnvelope, USER, type Envelope, type PeerId, type PeerState } from "./envelope.ts";
|
|
6
|
+
import { assign, detectSignals, LOCAL, type Assignment, type Routing } from "./routing.ts";
|
|
7
|
+
|
|
8
|
+
export interface TasksDeps {
|
|
9
|
+
board: Board;
|
|
10
|
+
bus: Bus;
|
|
11
|
+
routing: () => Routing;
|
|
12
|
+
cwd: string;
|
|
13
|
+
project: string;
|
|
14
|
+
briefs?: Briefs;
|
|
15
|
+
memory?: MemoryClient;
|
|
16
|
+
/** a line for the human: console tail and hub.log */
|
|
17
|
+
notify: (line: string) => void;
|
|
18
|
+
/** Optional: name a class for a task proposed without one. `onCampus` says whether the model call stays on campus. */
|
|
19
|
+
triage?: { classify: (title: string, detail: string) => Promise<TaskClass | undefined>; onCampus: () => Promise<boolean> };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ESCALATE_AFTER = 2;
|
|
23
|
+
const OPEN: Task["state"][] = ["proposed", "in_progress", "changes_requested"];
|
|
24
|
+
|
|
25
|
+
/** Tool callers are models: inputSchema is not enforced on the way in, so refs are normalized before they reach the board. */
|
|
26
|
+
function cleanRefs(input: unknown): TaskRefs {
|
|
27
|
+
const r = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
|
|
28
|
+
const text = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim().slice(0, 300) : undefined);
|
|
29
|
+
const paths = (Array.isArray(r.paths) ? r.paths : typeof r.paths === "string" ? [r.paths] : []).map(text).filter((p): p is string => !!p).slice(0, 50);
|
|
30
|
+
const out: TaskRefs = {};
|
|
31
|
+
for (const k of ["repo", "branch", "commit"] as const) if (text(r[k])) out[k] = text(r[k])!;
|
|
32
|
+
if (paths.length) out.paths = paths;
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The task flow. Adapters and tools never touch the board: every change comes through here, where assignment,
|
|
38
|
+
* PII handling, envelopes, briefs and memory notes are decided in one place.
|
|
39
|
+
*/
|
|
40
|
+
export class Tasks {
|
|
41
|
+
constructor(private readonly d: TasksDeps) {
|
|
42
|
+
// What the on-prem worker says about a PII task is private on the bus (console tail and log show a stub), so the
|
|
43
|
+
// board keeps the text: `ahub task show <id>` is where the console user reads it, a refusal included.
|
|
44
|
+
d.bus.tap((e) => {
|
|
45
|
+
if (e.t !== "envelope" || !e.env.private || e.env.from === HUB || !e.env.refs?.task) return;
|
|
46
|
+
try {
|
|
47
|
+
d.board.update(Number(e.env.refs.task), e.env.from, "answer", {}, e.env.body.slice(0, 4000));
|
|
48
|
+
} catch {
|
|
49
|
+
// the task is gone: nothing to attach the answer to
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
isPii = (task: Pick<Task, "signals">) => task.signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
|
|
55
|
+
|
|
56
|
+
/** What peers other than the owner, the console stream and the log may see of a task. */
|
|
57
|
+
publicTitle = (task: Task) => (this.isPii(task) ? `#${task.id} [pii]` : `#${task.id} ${task.title}`);
|
|
58
|
+
|
|
59
|
+
/** A task as a cloud peer may see it. */
|
|
60
|
+
publicView(task: Task): Record<string, unknown> {
|
|
61
|
+
const { title, detail, history, ...rest } = task;
|
|
62
|
+
return this.isPii(task) ? { ...rest, title: "[pii]", detail: "[pii]" } : { ...rest, title, detail, history: history.slice(-5) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private states(): Record<PeerId, PeerState> {
|
|
66
|
+
return Object.fromEntries([...this.d.bus.peers.keys()].map((id) => [id, this.d.bus.stateOf(id)]));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async propose(by: PeerId, input: { title?: string; detail?: string; class?: string; refs?: TaskRefs; owner?: PeerId }): Promise<Task> {
|
|
70
|
+
const title = String(input.title ?? "").trim().slice(0, 300); // callers are models: a title is a line, not a document
|
|
71
|
+
if (!title) throw new Error("title is required");
|
|
72
|
+
const given = input.class === undefined || input.class === "" ? undefined : input.class;
|
|
73
|
+
if (given !== undefined && !CLASSES.includes(given as TaskClass)) throw new Error(`class must be one of ${CLASSES.join(", ")}`);
|
|
74
|
+
const text = { title, detail: String(input.detail ?? "").slice(0, 8000), refs: cleanRefs(input.refs) };
|
|
75
|
+
const signals = detectSignals(text, this.d.routing(), this.d.cwd);
|
|
76
|
+
let cls = given as TaskClass | undefined;
|
|
77
|
+
let triaged = false;
|
|
78
|
+
if (!cls && this.d.triage) {
|
|
79
|
+
// Signals first: a PII task's text may only go to a model that is reached without leaving the campus network.
|
|
80
|
+
const pii = signals.includes("pii") && this.d.routing().constraints.pii === "local_only";
|
|
81
|
+
if (!pii || (await this.d.triage.onCampus().catch(() => false))) cls = await this.d.triage.classify(text.title, text.detail).catch(() => undefined);
|
|
82
|
+
triaged = !!cls;
|
|
83
|
+
}
|
|
84
|
+
if (!cls) throw new Error(`class is required (one of ${CLASSES.join(", ")}); the hub could not name one for you`);
|
|
85
|
+
const draft = { ...text, class: cls };
|
|
86
|
+
let task = this.d.board.propose(by, { ...draft, signals });
|
|
87
|
+
if (triaged) task = this.d.board.update(task.id, "hub", "triaged", {}, `class ${cls} named by the hub's model`);
|
|
88
|
+
this.d.notify(`task ${this.publicTitle(task)} proposed by ${by} [${task.class}]${task.signals.length ? ` signals: ${task.signals.join(", ")}` : ""}`);
|
|
89
|
+
return this.assignOwner(task, by, input.owner ? { candidates: [input.owner] } : {});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Same code as assignment, without doing it. */
|
|
93
|
+
explain(target: number | { title: string; detail?: string; class: TaskClass; refs?: TaskRefs }): string[] {
|
|
94
|
+
const routing = this.d.routing();
|
|
95
|
+
if (typeof target === "number") {
|
|
96
|
+
const task = this.d.board.get(target);
|
|
97
|
+
if (!task) throw new Error(`no task #${target}`);
|
|
98
|
+
return [`task ${this.publicTitle(task)} (${task.state}, owner ${task.owner ?? "none"})`, "if it were assigned now:", ...assign(task, this.states(), routing, { exclude: this.declined(task) }).trace];
|
|
99
|
+
}
|
|
100
|
+
const draft = { title: target.title, detail: target.detail ?? "", refs: target.refs ?? {} };
|
|
101
|
+
return assign({ class: target.class, signals: detectSignals(draft, routing, this.d.cwd) }, this.states(), routing).trace;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private declined = (task: Task) => task.history.filter((h) => h.event === "declined").map((h) => h.by);
|
|
105
|
+
|
|
106
|
+
private async assignOwner(task: Task, by: PeerId, opts: { candidates?: PeerId[]; event?: string; note?: string; clearOnFail?: boolean; exclude?: PeerId[]; context?: string } = {}): Promise<Task> {
|
|
107
|
+
const a = assign(task, this.states(), this.d.routing(), { exclude: [...this.declined(task), ...(opts.exclude ?? []), ...(opts.event === "escalated" && task.owner ? [task.owner] : [])], ...(opts.candidates ? { candidates: opts.candidates } : {}) });
|
|
108
|
+
if (!a.owner) {
|
|
109
|
+
this.d.notify(`task ${this.publicTitle(task)}: no peer can take it (${a.trace.filter((l) => l.includes("skipped")).length} skipped); assign with: ahub task assign ${task.id} <peer>`);
|
|
110
|
+
// Only a decline takes the task away from its owner; a failed console assign or escalation leaves it where it was.
|
|
111
|
+
return opts.clearOnFail && task.owner ? this.d.board.update(task.id, by, "unassigned", { owner: null }) : task;
|
|
112
|
+
}
|
|
113
|
+
const next = this.d.board.update(task.id, by, opts.event ?? "assigned", { owner: a.owner, reviewer: a.reviewer ?? null, ...(opts.event === "escalated" ? { rejections: 0 } : {}) }, opts.note ?? `to ${a.owner}`);
|
|
114
|
+
await this.sendTask(next, a, opts.context);
|
|
115
|
+
return next;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async sendTask(task: Task, a: Assignment, context?: string): Promise<void> {
|
|
119
|
+
const pii = this.isPii(task);
|
|
120
|
+
const brief = pii ? undefined : await this.d.briefs?.forTask(task.owner!, task).catch(() => undefined);
|
|
121
|
+
const rejected = task.history.filter((h) => h.event === "changes_requested").map((h) => `- ${h.by}: ${h.note ?? ""}`);
|
|
122
|
+
const facts = [`class ${task.class}`, task.refs.paths?.length ? `paths ${task.refs.paths.join(", ")}` : "", task.refs.branch ? `branch ${task.refs.branch}` : "", a.reviewer ? `reviewer ${a.reviewer}` : "no reviewer"].filter(Boolean).join("; ");
|
|
123
|
+
const body = [
|
|
124
|
+
`Task #${task.id} [${task.class}] ${task.title}`,
|
|
125
|
+
task.detail,
|
|
126
|
+
`Facts: ${facts}`,
|
|
127
|
+
rejected.length ? `Earlier review notes:\n${rejected.join("\n")}` : "",
|
|
128
|
+
brief ?? "",
|
|
129
|
+
// What the previous owner left behind. Peer-written free text: never attached to a PII task.
|
|
130
|
+
context && !pii ? `Handoff from the previous owner:\n${context.slice(0, 3000)}` : "",
|
|
131
|
+
`Take it with hub_task_accept {id: ${task.id}} or pass with hub_task_decline. When finished: hub_task_done {id: ${task.id}, summary, refs}.`,
|
|
132
|
+
].filter(Boolean).join("\n\n");
|
|
133
|
+
this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner!], kind: "task", priority: "important", refs: { ...task.refs, task: String(task.id) }, ...(pii ? { private: true } : {}) }));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private mine(task: Task, by: PeerId, role: "owner" | "reviewer"): void {
|
|
137
|
+
if (by !== USER && task[role] !== by) throw new Error(`task #${task.id}: only its ${role} (${task[role] ?? "none"}) or the console user can do that`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private need(id: unknown, open = false): Task {
|
|
141
|
+
const task = this.d.board.get(Number(id));
|
|
142
|
+
if (!task) throw new Error(`no task #${id}`);
|
|
143
|
+
// Handing a task to someone else only makes sense while there is work left on it.
|
|
144
|
+
if (open && !OPEN.includes(task.state)) throw new Error(`task #${task.id} is ${task.state}: it can no longer change hands`);
|
|
145
|
+
return task;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
accept(by: PeerId, id: unknown): Task {
|
|
149
|
+
const task = this.need(id);
|
|
150
|
+
this.mine(task, by, "owner");
|
|
151
|
+
const next = this.d.board.update(task.id, by, "accepted", { state: "in_progress" });
|
|
152
|
+
this.d.notify(`task ${this.publicTitle(next)} accepted by ${by}`);
|
|
153
|
+
return next;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async decline(by: PeerId, id: unknown, reason?: string): Promise<Task> {
|
|
157
|
+
const task = this.need(id, true);
|
|
158
|
+
this.mine(task, by, "owner");
|
|
159
|
+
const back = this.d.board.update(task.id, by, "declined", task.state === "in_progress" ? { state: "proposed" } : {}, reason);
|
|
160
|
+
this.d.notify(`task ${this.publicTitle(back)} declined by ${by}${reason && !this.isPii(back) ? `: ${reason}` : ""}`);
|
|
161
|
+
return this.assignOwner(back, HUB, { event: "reassigned", clearOnFail: true });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async done(by: PeerId, id: unknown, summary?: string, refs?: TaskRefs): Promise<Task> {
|
|
165
|
+
let task = this.need(id);
|
|
166
|
+
this.mine(task, by, "owner");
|
|
167
|
+
if (task.state === "in_review" || task.state === "approved") throw new Error(`task #${task.id} is already ${task.state}`);
|
|
168
|
+
if (task.state === "proposed" || task.state === "changes_requested") task = this.d.board.update(task.id, by, "accepted", { state: "in_progress" }); // done without a separate accept
|
|
169
|
+
const reviewer = task.reviewer;
|
|
170
|
+
const next = this.d.board.update(task.id, by, "done", { state: reviewer ? "in_review" : "approved", refs: cleanRefs(refs) }, summary);
|
|
171
|
+
this.note(next, by, "finding", `Task #${next.id} done by ${by}: ${next.title}\n${summary ?? ""}`);
|
|
172
|
+
if (!reviewer) this.d.notify(`task ${this.publicTitle(next)} done by ${by}, no reviewer: approved`);
|
|
173
|
+
else if (reviewer === USER) this.d.notify(`task ${this.publicTitle(next)} done by ${by}: review it with ahub task show ${next.id}, then ahub review ${next.id} approved|changes_requested [note]`);
|
|
174
|
+
else this.sendReview(next, reviewer);
|
|
175
|
+
return next;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The one place a review request is written: the first reviewer and a replacement get the same text, refs and privacy. */
|
|
179
|
+
private sendReview(task: Task, reviewer: PeerId, why = ""): void {
|
|
180
|
+
const r = task.refs;
|
|
181
|
+
const last = [...task.history].reverse().find((h) => h.event === "done");
|
|
182
|
+
const where = [r.branch ? `branch ${r.branch}` : "", r.commit ? `commit ${r.commit}` : "", r.paths?.length ? `paths ${r.paths.join(", ")}` : ""].filter(Boolean).join("; ");
|
|
183
|
+
const body = `Review task #${task.id} [${task.class}] ${task.title}\nDone by ${last?.by ?? task.owner}: ${last?.note ?? "(no summary)"}\n${where ? `Where: ${where}\n` : ""}${why ? `${why}\n` : ""}Give your verdict with hub_review {id: ${task.id}, verdict: "approved" | "changes_requested", note}.`;
|
|
184
|
+
this.d.bus.publish(newEnvelope(HUB, body, { to: [reviewer], kind: "review", priority: "important", refs: { ...r, task: String(task.id) }, ...(this.isPii(task) ? { private: true } : {}) }));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async review(by: PeerId, id: unknown, verdict: unknown, note?: string): Promise<Task> {
|
|
188
|
+
const task = this.need(id);
|
|
189
|
+
this.mine(task, by, "reviewer");
|
|
190
|
+
if (verdict !== "approved" && verdict !== "changes_requested") throw new Error('verdict must be "approved" or "changes_requested"');
|
|
191
|
+
if (task.state !== "in_review") throw new Error(`task #${task.id} is ${task.state}: cannot move to ${verdict} before its owner calls hub_task_done`);
|
|
192
|
+
const pii = this.isPii(task);
|
|
193
|
+
if (verdict === "approved") {
|
|
194
|
+
const next = this.d.board.update(task.id, by, "approved", { state: "approved", rejections: 0 }, note);
|
|
195
|
+
this.note(next, by, "decision", `Task #${next.id} approved by ${by}: ${next.title}\n${note ?? ""}`);
|
|
196
|
+
this.tell(next, `Task #${next.id} approved by ${by}.${note ? ` ${note}` : ""}`, pii);
|
|
197
|
+
return next;
|
|
198
|
+
}
|
|
199
|
+
const rejected = this.d.board.update(task.id, by, "changes_requested", { state: "changes_requested", rejections: task.rejections + 1 }, note);
|
|
200
|
+
this.note(rejected, by, "decision", `Task #${rejected.id} changes requested by ${by}: ${rejected.title}\n${note ?? ""}`);
|
|
201
|
+
if (rejected.rejections >= ESCALATE_AFTER) {
|
|
202
|
+
const moved = await this.escalate(HUB, rejected.id, `${rejected.rejections} consecutive changes_requested`);
|
|
203
|
+
if (moved.owner !== rejected.owner) return moved;
|
|
204
|
+
// Nobody to escalate to: the owner still has to hear the verdict and the note.
|
|
205
|
+
this.tell(moved, `Task #${moved.id}: ${by} requests changes again.${note ? ` ${note}` : ""} Nobody else can take it; fix it and call hub_task_done again.`, pii);
|
|
206
|
+
return moved;
|
|
207
|
+
}
|
|
208
|
+
const reopened = this.d.board.update(rejected.id, HUB, "reopened", { state: "in_progress" });
|
|
209
|
+
this.tell(reopened, `Task #${reopened.id}: ${by} requests changes.${note ? ` ${note}` : ""} Fix it and call hub_task_done again.`, pii);
|
|
210
|
+
return reopened;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Task-level escalation: the next attached peer in the class's escalate_to takes over, with the history. */
|
|
214
|
+
async escalate(by: PeerId, id: unknown, why = "by hand"): Promise<Task> {
|
|
215
|
+
let task = this.need(id, true);
|
|
216
|
+
const list = this.d.routing().classes[task.class]?.escalate_to ?? [];
|
|
217
|
+
if (task.state === "changes_requested") task = this.d.board.update(task.id, HUB, "reopened", { state: "in_progress" });
|
|
218
|
+
const from = task.owner;
|
|
219
|
+
const next = await this.assignOwner(task, by, { candidates: list, event: "escalated", note: `${why}; from ${from ?? "none"}` });
|
|
220
|
+
if (next.owner && next.owner !== from) {
|
|
221
|
+
this.d.notify(`task ${this.publicTitle(next)} escalated from ${from} to ${next.owner} (${why})`);
|
|
222
|
+
this.note(next, by, "decision", `Task #${next.id} escalated from ${from} to ${next.owner}: ${why}`);
|
|
223
|
+
if (from) this.tell({ ...next, owner: from }, `Task #${next.id} moved to ${next.owner} (${why}). Stop working on it.`, this.isPii(next));
|
|
224
|
+
} else this.d.notify(`task ${this.publicTitle(task)}: escalation found nobody in [${list.join(", ")}]; it stays with ${from ?? "nobody"}`);
|
|
225
|
+
return this.d.board.get(next.id)!;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Console only. */
|
|
229
|
+
async assignTo(id: unknown, peer: PeerId): Promise<Task> {
|
|
230
|
+
return this.assignOwner(this.need(id, true), USER, { candidates: [peer], event: "reassigned" });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Budget relay: a paused peer's open work moves on. `local` first (it has no quota), then the class list, all through
|
|
235
|
+
* the usual constraints. Tasks it was reviewing get another reviewer. Moves are reported, never taken back automatically.
|
|
236
|
+
*/
|
|
237
|
+
async reassignForPause(peer: PeerId, context: string | undefined): Promise<{ id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]> {
|
|
238
|
+
const moved: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[] = [];
|
|
239
|
+
const routing = this.d.routing();
|
|
240
|
+
for (const task of this.d.board.list()) {
|
|
241
|
+
if (task.owner === peer && OPEN.includes(task.state)) {
|
|
242
|
+
const candidates = [LOCAL, ...(routing.classes[task.class]?.peers ?? []).filter((p) => p !== LOCAL)];
|
|
243
|
+
const back = task.state === "in_progress" ? this.d.board.update(task.id, HUB, "released", { state: "proposed" }, `budget pause of ${peer}`) : task;
|
|
244
|
+
const next = await this.assignOwner(back, HUB, { candidates, exclude: [peer], event: "reassigned", note: `budget pause of ${peer}`, clearOnFail: true, ...(context ? { context } : {}) });
|
|
245
|
+
moved.push({ id: task.id, title: this.publicTitle(task), to: next.owner, role: "owner" });
|
|
246
|
+
} else if (task.reviewer === peer && task.state !== "approved") {
|
|
247
|
+
// No owner candidates: only the reviewer is wanted, and it must be neither the paused peer nor the task's owner.
|
|
248
|
+
const a = assign(task, this.states(), routing, { exclude: [peer], candidates: [], ...(task.owner ? { notReviewer: task.owner } : {}) });
|
|
249
|
+
const reviewer = a.reviewer && a.reviewer !== peer && a.reviewer !== task.owner ? a.reviewer : null;
|
|
250
|
+
const next = this.d.board.update(task.id, HUB, "reviewer changed", { reviewer }, `budget pause of ${peer}`);
|
|
251
|
+
moved.push({ id: task.id, title: this.publicTitle(task), to: reviewer, role: "reviewer" });
|
|
252
|
+
if (next.state === "in_review" && reviewer && reviewer !== USER) this.sendReview(next, reviewer, `(${peer} was reviewing this and is paused for quota.)`);
|
|
253
|
+
else if (next.state === "in_review" && !reviewer) this.d.notify(`task ${this.publicTitle(next)}: its reviewer ${peer} is paused and nobody else can review; use ahub review ${next.id}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return moved;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** What the local worker needs to know about the turn it is about to run. */
|
|
260
|
+
turnPolicy(envs: Envelope[]): { route?: string; fixedModel?: string; pii: boolean; task?: string } | undefined {
|
|
261
|
+
const found = envs.map((e) => (e.refs?.task ? this.d.board.get(Number(e.refs.task)) : undefined)).filter((t): t is Task => !!t);
|
|
262
|
+
// One PII item makes the whole turn a PII turn: a digest can carry a PII task next to ordinary messages.
|
|
263
|
+
const pii = envs.some((e) => e.private) || found.some((t) => this.isPii(t));
|
|
264
|
+
if (!found.length) return pii ? { pii } : undefined;
|
|
265
|
+
const policy = this.d.routing().classes[found[0]!.class];
|
|
266
|
+
const about = found.find((t) => this.isPii(t)) ?? found[0]!; // the answer is filed under the PII task when there is one
|
|
267
|
+
return { ...(policy?.route ? { route: policy.route } : {}), ...(policy?.fixed_model ? { fixedModel: policy.fixed_model } : {}), pii, task: String(about.id) };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async remember(by: PeerId, input: { text?: string; title?: string; kind?: string; task?: unknown }): Promise<string> {
|
|
271
|
+
const text = String(input.text ?? "").trim();
|
|
272
|
+
if (!text) throw new Error("text is required");
|
|
273
|
+
const task = input.task !== undefined ? this.d.board.get(Number(input.task)) : undefined;
|
|
274
|
+
if (task && this.isPii(task)) throw new Error("notes about a PII task are not saved: claude-mem processes what it stores with a cloud model");
|
|
275
|
+
if (!this.d.memory) return "memory is disabled; nothing saved";
|
|
276
|
+
const kind = ["decision", "finding", "contract"].includes(String(input.kind)) ? String(input.kind) : "finding";
|
|
277
|
+
const res = await this.d.memory.save({ text, ...(input.title ? { title: input.title } : {}), project: this.d.project, metadata: { peer: by, kind, ...(task ? { task: task.id } : {}) } });
|
|
278
|
+
return res ? "saved to shared memory" : "memory worker unavailable; nothing saved";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Auto notes: only transitions that carry content, never for PII. */
|
|
282
|
+
private note(task: Task, by: PeerId, kind: string, text: string): void {
|
|
283
|
+
if (this.isPii(task) || !this.d.memory) return;
|
|
284
|
+
void this.d.memory.save({ text, title: `agent-hub task #${task.id}: ${task.title}`.slice(0, 120), project: this.d.project, metadata: { peer: by, task: task.id, kind } });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private tell(task: Task, body: string, pii: boolean): void {
|
|
288
|
+
if (!task.owner || task.owner === USER) return;
|
|
289
|
+
this.d.bus.publish(newEnvelope(HUB, body, { to: [task.owner], kind: "task", priority: "important", refs: { task: String(task.id) }, ...(pii ? { private: true } : {}) }));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export { LOCAL };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { basename, sep } from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one denylist. tools.ts checks paths against it, sandbox.ts turns it into seatbelt rules, capture.ts keeps
|
|
5
|
+
* matching calls out of memory. Two lists would drift, and the gap is where a secret leaks.
|
|
6
|
+
*/
|
|
7
|
+
export const DENY_SEGMENTS = [".maru/secrets", ".agenthub/state"];
|
|
8
|
+
/** Regex sources, matched against a file's basename. */
|
|
9
|
+
export const DENY_NAMES = ["^\\.env", "\\.pem$", "\\.key$", "^id_rsa", "^id_ed25519", "^credentials(\\.|$)", "^auth\\.json$", "^\\.netrc$", "^\\.npmrc$", "^\\.pypirc$"];
|
|
10
|
+
|
|
11
|
+
const hasSegment = (rel: string, seg: string) => `/${rel}/`.includes(`/${seg}/`);
|
|
12
|
+
export { hasSegment };
|
|
13
|
+
|
|
14
|
+
/** True when a path names something the worker must never read, write or report. `extra`: substrings from `local.deny`. */
|
|
15
|
+
export function isDenied(path: string, extra: string[] = []): boolean {
|
|
16
|
+
const norm = path.split(sep).join("/");
|
|
17
|
+
return DENY_SEGMENTS.some((s) => hasSegment(norm, s)) || DENY_NAMES.some((re) => new RegExp(re).test(basename(norm))) || extra.some((e) => e && norm.includes(e));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const sbplEscape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/"/g, '\\"');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Seatbelt regex filters equivalent to isDenied. Seatbelt sees absolute paths, so the `local.deny` substrings are
|
|
24
|
+
* anchored under the project root: a bare "private/" would otherwise match /private/var/... and deny the whole temp tree.
|
|
25
|
+
*/
|
|
26
|
+
export function denyRegexes(root: string, extra: string[] = []): string[] {
|
|
27
|
+
const names = DENY_NAMES.map((re) => {
|
|
28
|
+
const body = re.startsWith("^") ? re.slice(1) : `[^/]*${re}`;
|
|
29
|
+
return `/${body}${body.endsWith("$") ? "" : "[^/]*$"}`;
|
|
30
|
+
});
|
|
31
|
+
return [
|
|
32
|
+
...DENY_SEGMENTS.map((s) => `/${sbplEscape(s)}(/|$)`),
|
|
33
|
+
...names,
|
|
34
|
+
...extra.filter(Boolean).map((e) => `^${sbplEscape(root)}/.*${sbplEscape(e)}`),
|
|
35
|
+
].map((re) => `(regex #"${re.replace(/"/g, '\\"')}")`);
|
|
36
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { denyRegexes } from "./deny.ts";
|
|
6
|
+
|
|
7
|
+
const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
8
|
+
export const OUTPUT_CAP = 20_000;
|
|
9
|
+
|
|
10
|
+
export const sandboxAvailable = () => process.platform === "darwin" && existsSync(SANDBOX_EXEC);
|
|
11
|
+
|
|
12
|
+
const q = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Seatbelt profile for everything the local worker executes (bash, git). Path checks cannot scope a shell,
|
|
16
|
+
* so the kernel does: writes only under cwd and temp, no reads of credential stores or denylisted files,
|
|
17
|
+
* no network unless asked. In SBPL the last matching rule wins, so the denies come last.
|
|
18
|
+
*/
|
|
19
|
+
/** Toolchains and git identity: the only parts of the home directory a sandboxed command may read besides the project. */
|
|
20
|
+
const HOME_READABLE = [".bun", ".cargo", ".rustup", ".local", ".npm", ".cache", ".pyenv", ".nvm", ".deno", "go", ".gitconfig", ".config/git", "Library/Caches"];
|
|
21
|
+
|
|
22
|
+
/** A submodule or worktree keeps its git dir outside the project; git needs it, minus the parts that execute or reconfigure. */
|
|
23
|
+
function externalGitDirs(root: string): string[] {
|
|
24
|
+
const out = spawnSync("git", ["-C", root, "rev-parse", "--absolute-git-dir", "--git-common-dir"], { encoding: "utf8" });
|
|
25
|
+
if (out.status !== 0) return [];
|
|
26
|
+
const dirs = out.stdout.trim().split("\n").map((d) => resolve(root, d));
|
|
27
|
+
return [...new Set(dirs)].filter((d) => !d.startsWith(`${root}/`));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function profile(cwd: string, network: boolean, readAllow: string[] = [], deny: string[] = []): string {
|
|
31
|
+
const home = homedir();
|
|
32
|
+
const inHome = (p: string) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
|
|
33
|
+
const root = realpathSync(cwd);
|
|
34
|
+
const tmp = realpathSync(tmpdir());
|
|
35
|
+
const gitDirs = externalGitDirs(root);
|
|
36
|
+
const creds = [".ssh", ".aws", ".gnupg", ".config/gh", ".config/gcloud", ".kube", ".docker", ".netrc", ".npmrc", ".omniroute", ".claude", ".codex", ".kimi-code", "Library/Keychains"];
|
|
37
|
+
return [
|
|
38
|
+
"(version 1)",
|
|
39
|
+
"(allow default)",
|
|
40
|
+
...(network ? [] : ["(deny network*)"]),
|
|
41
|
+
// Home is default-deny for reads: whatever a command reads can end up in the model's answer, and that answer
|
|
42
|
+
// is shared with agents that run on cloud subscriptions (~/.claude.json, app tokens, browser profiles ...).
|
|
43
|
+
`(deny file-read* (subpath ${q(home)}))`,
|
|
44
|
+
`(allow file-read-metadata (subpath ${q(home)}))`,
|
|
45
|
+
`(allow file-read* (subpath ${q(root)}) ${[...HOME_READABLE.map((p) => join(home, p)), ...readAllow.map(inHome), ...gitDirs].map((p) => `(subpath ${q(p)})`).join(" ")})`,
|
|
46
|
+
"(deny file-write*)",
|
|
47
|
+
`(allow file-write* (subpath ${q(root)}) (subpath ${q(tmp)}) (subpath "/private/tmp") (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`,
|
|
48
|
+
// Inside cwd: nothing that runs later outside the sandbox, nothing that reconfigures the hub.
|
|
49
|
+
`(deny file-write* (subpath ${q(join(root, ".agenthub"))}) ${[join(root, ".git"), ...gitDirs].map((d) => `(subpath ${q(join(d, "hooks"))}) (literal ${q(join(d, "config"))})`).join(" ")})`,
|
|
50
|
+
`(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")})`,
|
|
51
|
+
`(deny file-read* file-write* ${denyRegexes(root, deny).join(" ")})`,
|
|
52
|
+
].join("\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ExecResult {
|
|
56
|
+
code: number | null;
|
|
57
|
+
output: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Run argv under seatbelt with a scrubbed environment. Never runs unsandboxed: no sandbox, no exec. */
|
|
61
|
+
export function sandboxedExec(argv: string[], opts: { cwd: string; profile: string; timeoutMs?: number }): Promise<ExecResult> {
|
|
62
|
+
if (!sandboxAvailable()) return Promise.resolve({ code: null, output: "error: command execution needs macOS sandbox-exec and is disabled on this host" });
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
const env = { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: homedir(), LANG: process.env.LANG ?? "en_US.UTF-8", TERM: "dumb", TMPDIR: tmpdir() };
|
|
65
|
+
// Own process group: a timeout has to take the grandchildren too, or they keep the pipes open and the project writable.
|
|
66
|
+
const child = spawn(SANDBOX_EXEC, ["-p", opts.profile, ...argv], { cwd: opts.cwd, env, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
67
|
+
let output = "";
|
|
68
|
+
const take = (d: Buffer) => {
|
|
69
|
+
if (output.length < OUTPUT_CAP) output += d.toString();
|
|
70
|
+
};
|
|
71
|
+
child.stdout.on("data", take);
|
|
72
|
+
child.stderr.on("data", take);
|
|
73
|
+
const killGroup = () => {
|
|
74
|
+
try {
|
|
75
|
+
process.kill(-child.pid!, "SIGKILL");
|
|
76
|
+
} catch {
|
|
77
|
+
child.kill("SIGKILL");
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const timer = setTimeout(killGroup, opts.timeoutMs ?? 120_000);
|
|
81
|
+
let done = false;
|
|
82
|
+
const finish = (code: number | null, signal: NodeJS.Signals | null) => {
|
|
83
|
+
if (done) return;
|
|
84
|
+
done = true;
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
const clipped = output.length >= OUTPUT_CAP ? `${output.slice(0, OUTPUT_CAP)}\n(output truncated)` : output;
|
|
87
|
+
resolve({ code, output: signal ? `${clipped}\n(killed: ${signal}, timeout?)` : clipped });
|
|
88
|
+
};
|
|
89
|
+
child.on("error", (e) => (done || ((done = true), resolve({ code: null, output: `error: ${e.message}` }))));
|
|
90
|
+
child.on("close", finish);
|
|
91
|
+
// `close` waits for every holder of the pipes; after `exit` give stragglers a moment, then stop waiting and reap them.
|
|
92
|
+
child.on("exit", (code, signal) => setTimeout(() => (killGroup(), finish(code, signal)), 500).unref());
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { lstatSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { hasSegment, isDenied } from "./deny.ts";
|
|
4
|
+
import { OUTPUT_CAP, sandboxedExec } from "./sandbox.ts";
|
|
5
|
+
|
|
6
|
+
export { isDenied };
|
|
7
|
+
|
|
8
|
+
export interface ToolContext {
|
|
9
|
+
cwd: string;
|
|
10
|
+
/** Extra denylist entries from config: substrings of the project-relative path. */
|
|
11
|
+
deny: string[];
|
|
12
|
+
/** Seatbelt profile for this project, built once per worker (`profile()` in sandbox.ts): it spawns git and must not run per tool call. */
|
|
13
|
+
sandboxProfile: string;
|
|
14
|
+
/** Ask the console. Resolves false on deny or timeout. */
|
|
15
|
+
permit: (title: string) => Promise<boolean>;
|
|
16
|
+
/** Publish a message to other peers mid-turn. Returns a one-line receipt. */
|
|
17
|
+
send: (text: string, to?: string[]) => string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Not secret, but writing them changes what runs outside the worker's control. */
|
|
21
|
+
const WRITE_DENY_SEGMENTS = [".git", ".agenthub"];
|
|
22
|
+
|
|
23
|
+
const lexists = (p: string) => {
|
|
24
|
+
try {
|
|
25
|
+
lstatSync(p);
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Absolute path inside cwd after resolving symlinks, or an Error. `write` also refuses .git and .agenthub. */
|
|
33
|
+
export function guardPath(ctx: Pick<ToolContext, "cwd" | "deny">, path: string, mode: "read" | "write"): string {
|
|
34
|
+
const root = realpathSync(ctx.cwd);
|
|
35
|
+
const abs = resolve(root, path);
|
|
36
|
+
// Resolve symlinks on the deepest part that exists: a link pointing outside must not pass as an inside path.
|
|
37
|
+
// lstat, not exists: a dangling symlink "does not exist" to existsSync, yet a write through it lands at its target.
|
|
38
|
+
let existing = abs;
|
|
39
|
+
while (!lexists(existing)) existing = dirname(existing);
|
|
40
|
+
let realExisting: string;
|
|
41
|
+
try {
|
|
42
|
+
realExisting = realpathSync(existing);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new Error(`${path} goes through a dangling symlink`);
|
|
45
|
+
}
|
|
46
|
+
const real = join(realExisting, relative(existing, abs));
|
|
47
|
+
const rel = relative(root, real);
|
|
48
|
+
if (rel.startsWith("..") || resolve(root, rel) !== real) throw new Error(`${path} is outside the project directory`);
|
|
49
|
+
if (isDenied(rel, ctx.deny)) throw new Error(`${path} is on the secrets denylist`);
|
|
50
|
+
if (mode === "write" && WRITE_DENY_SEGMENTS.some((s) => hasSegment(rel, s))) throw new Error(`${path} is not writable by the local worker`);
|
|
51
|
+
return real;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Shown to the person approving: enough of the content to know what they approve. */
|
|
55
|
+
const preview = (text: string, n = 1500) => (text.length > n ? `${text.slice(0, n)}\n... (${text.length - n} more chars)` : text);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* git takes paths and revisions that never pass through guardPath: `diff --no-index /dev/null ~/.cargo/credentials.toml`,
|
|
59
|
+
* `show HEAD:.env`. Every argument is checked: nothing absolute, nothing with `..`, nothing on the denylist.
|
|
60
|
+
*/
|
|
61
|
+
function gitArgsProblem(args: string[], deny: string[]): string | undefined {
|
|
62
|
+
for (const arg of args.slice(1)) {
|
|
63
|
+
if (arg === "--no-index" || arg.startsWith("--output") || arg.startsWith("--ext-diff") || arg.startsWith("--textconv")) return `${arg} is not available`;
|
|
64
|
+
const path = arg.includes(":") ? arg.slice(arg.indexOf(":") + 1) : arg.replace(/^--[a-z-]+=/, "");
|
|
65
|
+
if (path.startsWith("/") || path.startsWith("~") || path.split("/").includes("..")) return `${arg} points outside the project`;
|
|
66
|
+
if (isDenied(path, deny)) return `${arg} is on the secrets denylist`;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const GIT_READ = new Set(["status", "diff", "log", "show", "ls-files", "blame", "rev-parse"]);
|
|
72
|
+
const GIT_WRITE = new Set(["add", "commit", "restore", "checkout", "switch", "stash", "mv", "rm", "branch", "merge", "rebase", "cherry-pick", "revert", "tag"]);
|
|
73
|
+
|
|
74
|
+
const str = { type: "string" } as const;
|
|
75
|
+
const fn = (name: string, description: string, properties: Record<string, unknown>, required: string[]) => ({
|
|
76
|
+
type: "function",
|
|
77
|
+
function: { name, description, parameters: { type: "object", properties, required, additionalProperties: false } },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const TOOL_SCHEMAS = [
|
|
81
|
+
fn("read", "Read a text file inside the project. Returns numbered lines.", { path: str, offset: { type: "integer", minimum: 1 }, limit: { type: "integer", minimum: 1 } }, ["path"]),
|
|
82
|
+
fn("write", "Create or overwrite a file inside the project. Needs the user's approval.", { path: str, content: str }, ["path", "content"]),
|
|
83
|
+
fn("edit", "Replace one exact, unique occurrence of `old` with `new` in a file. Needs the user's approval.", { path: str, old: str, new: str }, ["path", "old", "new"]),
|
|
84
|
+
fn("bash", "Run a shell command in the project directory, sandboxed: no writes outside the project, no credential reads, no network unless the hub enables it. Needs the user's approval.", { command: str, timeout_s: { type: "integer", minimum: 1, maximum: 600 } }, ["command"]),
|
|
85
|
+
fn("git", "Run git in the project. status, diff, log, show, ls-files, blame, rev-parse run freely; add, commit, restore, checkout, switch, stash, mv, rm, branch, merge, rebase, cherry-pick, revert, tag need approval; everything else (push, fetch, config, remote ...) is refused.", { args: { type: "array", items: str, minItems: 1 } }, ["args"]),
|
|
86
|
+
fn("hub_send", "Send a message to the other agents now, before your turn ends. Conclusions only. Your final answer is shared anyway; use this for something that cannot wait.", { text: str, to: { type: "array", items: str } }, ["text"]),
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
/** Every path-like argument of a call, for the memory capture filter. */
|
|
90
|
+
export function touchedPaths(name: string, args: Record<string, unknown>): string[] {
|
|
91
|
+
if (name === "read" || name === "write" || name === "edit") return [String(args.path ?? "")];
|
|
92
|
+
if (name === "bash") return [String(args.command ?? "")];
|
|
93
|
+
if (name === "git") return (Array.isArray(args.args) ? args.args : []).map(String);
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Executes one tool call. Never throws: every failure comes back as text for the model to read. */
|
|
98
|
+
export async function runTool(name: string, rawArgs: string, ctx: ToolContext): Promise<string> {
|
|
99
|
+
let a: Record<string, any>;
|
|
100
|
+
try {
|
|
101
|
+
a = JSON.parse(rawArgs || "{}");
|
|
102
|
+
} catch {
|
|
103
|
+
return "error: tool arguments were not valid JSON";
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
switch (name) {
|
|
107
|
+
case "read": {
|
|
108
|
+
const file = guardPath(ctx, String(a.path), "read");
|
|
109
|
+
if (statSync(file).size > 5_000_000) return "error: file is larger than 5 MB; use bash (head, grep, sed -n) to look at parts of it";
|
|
110
|
+
const lines = readFileSync(file, "utf8").split("\n");
|
|
111
|
+
const from = Math.max(1, Number(a.offset) || 1);
|
|
112
|
+
const out = lines.slice(from - 1, from - 1 + (Number(a.limit) || 2000)).map((l, i) => `${from + i}\t${l}`).join("\n");
|
|
113
|
+
return out.length > OUTPUT_CAP ? `${out.slice(0, OUTPUT_CAP)}\n(truncated: read again with offset/limit)` : out;
|
|
114
|
+
}
|
|
115
|
+
case "write": {
|
|
116
|
+
const file = guardPath(ctx, String(a.path), "write");
|
|
117
|
+
const content = String(a.content ?? "");
|
|
118
|
+
if (!(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}`))) return "error: the user did not approve this write";
|
|
119
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
120
|
+
writeFileSync(file, content);
|
|
121
|
+
return `wrote ${a.path}`;
|
|
122
|
+
}
|
|
123
|
+
case "edit": {
|
|
124
|
+
const file = guardPath(ctx, String(a.path), "write");
|
|
125
|
+
const text = readFileSync(file, "utf8");
|
|
126
|
+
const count = text.split(String(a.old)).length - 1;
|
|
127
|
+
if (!a.old || count !== 1) return `error: \`old\` must match exactly once, it matched ${count} times`;
|
|
128
|
+
if (!(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}`))) return "error: the user did not approve this edit";
|
|
129
|
+
writeFileSync(file, text.replace(String(a.old), () => String(a.new ?? "")));
|
|
130
|
+
return `edited ${a.path}`;
|
|
131
|
+
}
|
|
132
|
+
case "bash": {
|
|
133
|
+
const command = String(a.command ?? "");
|
|
134
|
+
if (!command.trim()) return "error: empty command";
|
|
135
|
+
// The approver sees the whole command, not a prefix: what is hidden cannot be approved.
|
|
136
|
+
if (command.length > 4000) return "error: command longer than 4000 characters; put it in a script file with write, then run that";
|
|
137
|
+
if (!(await ctx.permit(`bash: ${command}`))) return "error: the user did not approve this command";
|
|
138
|
+
const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, timeoutMs: (Number(a.timeout_s) || 120) * 1000 });
|
|
139
|
+
return `${res.output}\n(exit ${res.code})`;
|
|
140
|
+
}
|
|
141
|
+
case "git": {
|
|
142
|
+
const args: string[] = Array.isArray(a.args) ? a.args.map(String) : [];
|
|
143
|
+
const sub = args[0] ?? "";
|
|
144
|
+
if (!GIT_READ.has(sub) && !GIT_WRITE.has(sub)) return `error: git ${sub} is not available to the local worker`;
|
|
145
|
+
const problem = gitArgsProblem(args, ctx.deny);
|
|
146
|
+
if (problem) return `error: git ${sub}: ${problem}`;
|
|
147
|
+
if (GIT_WRITE.has(sub) && !(await ctx.permit(`git ${args.join(" ")}`))) return "error: the user did not approve this git command";
|
|
148
|
+
// Sandboxed like bash: flags such as --output or an editor cannot write outside the project or reach the network.
|
|
149
|
+
const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile });
|
|
150
|
+
return `${res.output}\n(exit ${res.code})`;
|
|
151
|
+
}
|
|
152
|
+
case "hub_send":
|
|
153
|
+
return a.text ? ctx.send(String(a.text), Array.isArray(a.to) ? a.to.map(String) : undefined) : "error: text is required";
|
|
154
|
+
default:
|
|
155
|
+
return `error: unknown tool ${name}`;
|
|
156
|
+
}
|
|
157
|
+
} catch (e) {
|
|
158
|
+
return `error: ${(e as Error).message}`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { PeerId } from "../hub/envelope.ts";
|
|
2
|
+
import type { MemoryClient } from "./client.ts";
|
|
3
|
+
|
|
4
|
+
export interface BriefItem {
|
|
5
|
+
id: number;
|
|
6
|
+
time: string;
|
|
7
|
+
type: string;
|
|
8
|
+
title: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Rows of claude-mem's index tables: `| #65499 | 12:30 PM | ◆ | Title | ~372 |`. A row that does not match is skipped, never an error. */
|
|
12
|
+
export function parseRows(markdown: string | undefined): BriefItem[] {
|
|
13
|
+
const out: BriefItem[] = [];
|
|
14
|
+
for (const m of (markdown ?? "").matchAll(/^\|\s*#(\d+)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*(.*?)\s*\|/gm)) {
|
|
15
|
+
out.push({ id: Number(m[1]), time: m[2]!, type: m[3]!, title: m[4]!.replace(/\s*<-\s*\*\*ANCHOR\*\*/, "") });
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Index first, then the neighbourhood of the top hit: the retrieval both task briefs and `ahub ask` use. Deduplicated, in order. */
|
|
21
|
+
export async function related(client: MemoryClient, project: string, query: string, around = 3): Promise<BriefItem[]> {
|
|
22
|
+
const hits = parseRows(await client.search(query.slice(0, 300), project, 10));
|
|
23
|
+
if (!hits.length) return [];
|
|
24
|
+
const near = parseRows(await client.timeline(hits[0]!.id, project, around, around));
|
|
25
|
+
const out: BriefItem[] = [];
|
|
26
|
+
for (const item of [...hits, ...near]) if (!out.some((o) => o.id === item.id)) out.push(item);
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Task briefs: what memory already knows about a piece of work, handed over with it. Index first (search), then the
|
|
32
|
+
* neighbourhood of the top hit (timeline); details stay in claude-mem for the receiver to fetch by id. A peer is never
|
|
33
|
+
* shown the same observation twice in one hub run.
|
|
34
|
+
*/
|
|
35
|
+
export class Briefs {
|
|
36
|
+
private readonly seen = new Map<PeerId, Set<number>>();
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly client: MemoryClient,
|
|
40
|
+
private readonly project: string,
|
|
41
|
+
private readonly maxItems = 8,
|
|
42
|
+
) {}
|
|
43
|
+
|
|
44
|
+
async forTask(peer: PeerId, task: { title: string; refs: { paths?: string[] } }): Promise<string | undefined> {
|
|
45
|
+
const found = await related(this.client, this.project, [task.title, ...(task.refs.paths ?? [])].join(" "));
|
|
46
|
+
if (!found.length) return undefined;
|
|
47
|
+
const seen = this.seen.get(peer) ?? new Set<number>();
|
|
48
|
+
this.seen.set(peer, seen);
|
|
49
|
+
const items: BriefItem[] = [];
|
|
50
|
+
for (const item of found) {
|
|
51
|
+
if (seen.has(item.id)) continue;
|
|
52
|
+
items.push(item);
|
|
53
|
+
if (items.length === this.maxItems) break;
|
|
54
|
+
}
|
|
55
|
+
if (!items.length) return undefined;
|
|
56
|
+
for (const item of items) seen.add(item.id);
|
|
57
|
+
return ["Memory brief (claude-mem; fetch details by id if you need them):", ...items.map((i) => `#${i.id} ${i.time} ${i.type} ${i.title}`)].join("\n");
|
|
58
|
+
}
|
|
59
|
+
}
|