@a-t-h-i/bot-lobby 0.6.59 → 0.6.60
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/package.json +7 -7
- package/prompts/global.md +2 -1
- package/prompts/worker.md +2 -9
- package/src/classifier/files.ts +18 -6
- package/src/classifier/instance.ts +2 -2
- package/src/classifier/knowledge.ts +124 -1
- package/src/desk/board-client.ts +79 -12
- package/src/desk/board.ts +63 -52
- package/src/desk/delivery.ts +243 -0
- package/src/desk/ipc.ts +34 -1
- package/src/desk/lock.ts +81 -0
- package/src/desk/session.ts +85 -18
- package/src/execution/agent-runner.ts +10 -2
- package/src/execution/delegation.ts +160 -7
- package/src/execution/output-artifacts.ts +176 -0
- package/src/index.ts +3 -0
- package/src/knowledge/bookmarks.ts +452 -0
- package/src/knowledge/catalog-tools.ts +145 -0
- package/src/knowledge/catalogue.ts +550 -0
- package/src/knowledge/file-notes.ts +16 -6
- package/src/knowledge/repo-map.ts +67 -2
- package/src/knowledge/retrieval.ts +431 -0
- package/src/master/master.ts +132 -21
- package/src/pi/events.ts +31 -2
- package/src/pi/tools.ts +12 -1
- package/src/prompts/compiler.ts +24 -7
- package/src/schemas/configuration.ts +4 -0
- package/src/schemas/task.ts +32 -0
- package/src/state/checkpoint.ts +199 -0
- package/src/state/persistence.ts +138 -3
- package/src/workflow/workflow.ts +62 -11
|
@@ -13,7 +13,11 @@ import { dataRoot, detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
|
13
13
|
import { EditLog } from "../state/changes.ts";
|
|
14
14
|
import { createLiveRun } from "./live-run.ts";
|
|
15
15
|
import { runPiAgent, type PiRunOptions, type ProcessRunner } from "./pi-runner.ts";
|
|
16
|
-
import {
|
|
16
|
+
import { openOutputArtifact, type OutputArtifactAccess } from "./output-artifacts.ts";
|
|
17
|
+
import { artifactIdentity, startDeskServer, type DeskRequest, type DeskResponse } from "../desk/ipc.ts";
|
|
18
|
+
import { assignmentRecord, issueAssignment, loadTask, recordArtifactRef, taskDirFor } from "../state/persistence.ts";
|
|
19
|
+
import { persistedRunMarks } from "../state/checkpoint.ts";
|
|
20
|
+
import { TERMINAL_STATES, type AssignmentGrant, type AssignmentRecord, type TaskState } from "../schemas/task.ts";
|
|
17
21
|
import { DESK_ENV, DeskSession, deskSessionAt, PEER_TOOLS } from "../desk/session.ts";
|
|
18
22
|
import { BOARD_ENV } from "../desk/board.ts";
|
|
19
23
|
import { mcpGrant } from "../pi/grants.ts";
|
|
@@ -21,6 +25,7 @@ import { mcpGrant } from "../pi/grants.ts";
|
|
|
21
25
|
export const MAX_CHILDREN = 5;
|
|
22
26
|
export const DELEGATE_TOOL = "delegate_subtasks";
|
|
23
27
|
export const EXPERT_TOOL = "ask_expert";
|
|
28
|
+
export const DURABLE_OUTPUT_TOOL = "read_durable_output_artifacts";
|
|
24
29
|
/** Consults one step may make: advice on the hard part, not a second worker. */
|
|
25
30
|
export const MAX_CONSULTS = 2;
|
|
26
31
|
/** Longest an expert may take to answer. */
|
|
@@ -50,6 +55,19 @@ export function canConsult(agent: { runId: string; taskId: string; role: Role; d
|
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
/** One shared child pool per task; parents waiting on children never hold its slots. */
|
|
58
|
+
/** A child host: no delegation of its own, but durable reads under its own assignment when issued one. */
|
|
59
|
+
async function childHost(options: PiRunOptions, policy: AgentPolicy, tools: string[], mcpTools: string[], artifactTool: string | undefined) {
|
|
60
|
+
const note = options.env?.[EXPERT_ENV] ? "You are advising a worker: read what you need, answer its question, and change nothing." : "You are a child agent. Finish only your assigned brief and report evidence to your parent; you cannot delegate further.";
|
|
61
|
+
const access = artifactTool ? options.runtimeAccess : undefined;
|
|
62
|
+
if (!access) return { tools, mcpTools, env: { ...options.env, [AGENT_ENV]: JSON.stringify(policy), [DELEGATION_ENV]: "" }, note, close: async () => {}, onStart: options.onStart, children: [] as AgentRun[] };
|
|
63
|
+
const token = randomUUID();
|
|
64
|
+
const server = await startDeskServer(async (request) => {
|
|
65
|
+
if (request.worker !== token) return { ok: false, text: "Unknown parent agent." };
|
|
66
|
+
return artifactPage(access, request);
|
|
67
|
+
}, () => {});
|
|
68
|
+
return { tools, mcpTools, env: { ...options.env, [AGENT_ENV]: JSON.stringify(policy), [DELEGATION_ENV]: JSON.stringify({ address: server.address, token, assignment: access.assignmentId }) }, note, close: async () => { await server.close(); }, onStart: options.onStart, children: [] as AgentRun[] };
|
|
69
|
+
}
|
|
70
|
+
|
|
53
71
|
const pools = new Map<string, { active: number; users: number; waiters: Array<() => void> }>();
|
|
54
72
|
|
|
55
73
|
async function inPool<T>(key: string, limit: number, signal: AbortSignal, work: () => Promise<T>): Promise<T> {
|
|
@@ -85,6 +103,132 @@ export interface AgentPolicy {
|
|
|
85
103
|
depth: number;
|
|
86
104
|
}
|
|
87
105
|
|
|
106
|
+
const ARTIFACT_REF_PATTERN = /^O-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/;
|
|
107
|
+
|
|
108
|
+
/** What RuntimeAccess.checkpoint() exposes: task state and run marks, nothing else. */
|
|
109
|
+
export interface RuntimeCheckpointView {
|
|
110
|
+
taskState: TaskState;
|
|
111
|
+
runMarks: string[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The parent-side scope a child assignment is issued within. */
|
|
115
|
+
export interface AssignmentScope {
|
|
116
|
+
taskId: string;
|
|
117
|
+
ownerSessionId: string;
|
|
118
|
+
runId: string;
|
|
119
|
+
worktree: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A child run's identity: a new assignment is minted for it, never credentials reuse. */
|
|
123
|
+
export interface ChildIdentity {
|
|
124
|
+
runId: string;
|
|
125
|
+
worktree: string;
|
|
126
|
+
domain: Domain;
|
|
127
|
+
role: Role;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Assignment-scoped durable authorization: reads only, revalidated live against the task registry. */
|
|
131
|
+
export interface RuntimeAccess {
|
|
132
|
+
readonly assignmentId: string;
|
|
133
|
+
readonly ownerSessionId: string;
|
|
134
|
+
authorize(grant: string, kind?: string, ref?: string): boolean;
|
|
135
|
+
readonly artifactAccess: OutputArtifactAccess;
|
|
136
|
+
checkpoint(): RuntimeCheckpointView | undefined;
|
|
137
|
+
issueChild(parentScope: AssignmentScope, child: ChildIdentity): RuntimeAccess | undefined;
|
|
138
|
+
recordArtifact(ref: string): void;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface RuntimeAccessOptions {
|
|
142
|
+
root: string;
|
|
143
|
+
configDir: string;
|
|
144
|
+
taskId: string;
|
|
145
|
+
ownerSessionId: string;
|
|
146
|
+
runId: string;
|
|
147
|
+
worktree: string;
|
|
148
|
+
domain: Domain;
|
|
149
|
+
role: Role;
|
|
150
|
+
grants?: readonly AssignmentGrant[];
|
|
151
|
+
parentAssignmentId?: string;
|
|
152
|
+
signal?: AbortSignal;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Issues one assignment identity and returns its live-validated access; the registry is the sole authority. */
|
|
156
|
+
export function createRuntimeAccess(options: RuntimeAccessOptions): RuntimeAccess | undefined {
|
|
157
|
+
const issued = issueAssignment(options.root, options.configDir, options.taskId, {
|
|
158
|
+
ownerSessionId: options.ownerSessionId,
|
|
159
|
+
runId: options.runId,
|
|
160
|
+
worktree: options.worktree,
|
|
161
|
+
domain: options.domain,
|
|
162
|
+
role: options.role,
|
|
163
|
+
...(options.grants ? { grants: options.grants } : {}),
|
|
164
|
+
...(options.parentAssignmentId ? { parentAssignmentId: options.parentAssignmentId } : {}),
|
|
165
|
+
});
|
|
166
|
+
if (!issued) return undefined;
|
|
167
|
+
const childKeys: string[] = [];
|
|
168
|
+
const view = runtimeView(options, issued, childKeys);
|
|
169
|
+
return {
|
|
170
|
+
assignmentId: issued.id,
|
|
171
|
+
ownerSessionId: issued.ownerSessionId,
|
|
172
|
+
authorize: view.authorize,
|
|
173
|
+
artifactAccess: view.artifactAccess,
|
|
174
|
+
checkpoint: view.checkpoint,
|
|
175
|
+
issueChild: (parentScope, child) => issueChildAccess(options, issued, parentScope, child, childKeys),
|
|
176
|
+
recordArtifact: (ref) => { recordArtifactRef(options.root, options.configDir, options.taskId, ref); },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Every check re-reads the registry: revoked, cancelled, unowned or terminal always deny. */
|
|
181
|
+
function runtimeView(options: RuntimeAccessOptions, issued: AssignmentRecord, childKeys: string[]) {
|
|
182
|
+
const authorize = (grant: string, kind?: string, ref?: string): boolean => {
|
|
183
|
+
if (options.signal?.aborted) return false;
|
|
184
|
+
const record = assignmentRecord(options.root, options.configDir, options.taskId, issued.id);
|
|
185
|
+
if (!record || record.state !== "active" || !record.grants.includes(grant as AssignmentGrant)) return false;
|
|
186
|
+
if (kind === "artifact" && ref !== undefined && !ARTIFACT_REF_PATTERN.test(ref)) return false;
|
|
187
|
+
const task = loadTask(options.root, options.configDir, options.taskId);
|
|
188
|
+
return !!task && task.ownerSessionId === record.ownerSessionId && !TERMINAL_STATES.includes(task.state);
|
|
189
|
+
};
|
|
190
|
+
const artifactAccess: OutputArtifactAccess = {
|
|
191
|
+
repository: options.root,
|
|
192
|
+
worktree: options.worktree,
|
|
193
|
+
taskDir: taskDirFor(options.root, options.configDir, options.taskId),
|
|
194
|
+
taskId: options.taskId,
|
|
195
|
+
callerKey: issued.id,
|
|
196
|
+
allowedCallerKeys: childKeys,
|
|
197
|
+
onRead: (ref) => { recordArtifactRef(options.root, options.configDir, options.taskId, ref); },
|
|
198
|
+
authorize: () => authorize("artifact.read"),
|
|
199
|
+
};
|
|
200
|
+
return { authorize, artifactAccess, checkpoint: () => checkpointView(options, issued, authorize) };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Run marks pass through an access callback, so an unauthorized caller reads none of them. */
|
|
204
|
+
function checkpointView(options: RuntimeAccessOptions, issued: AssignmentRecord, authorize: RuntimeAccess["authorize"]): RuntimeCheckpointView | undefined {
|
|
205
|
+
if (!authorize("checkpoint.read")) return undefined;
|
|
206
|
+
const task = loadTask(options.root, options.configDir, options.taskId);
|
|
207
|
+
if (!task || task.ownerSessionId !== issued.ownerSessionId) return undefined;
|
|
208
|
+
return { taskState: task.state, runMarks: persistedRunMarks(task.checkpoint, () => true) };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Children get fresh identities within the parent's own scope, with the parent's grants. */
|
|
212
|
+
function issueChildAccess(options: RuntimeAccessOptions, issued: AssignmentRecord, parentScope: AssignmentScope, child: ChildIdentity, childKeys: string[]): RuntimeAccess | undefined {
|
|
213
|
+
if (issued.state !== "active") return undefined;
|
|
214
|
+
if (parentScope.taskId !== options.taskId || parentScope.ownerSessionId !== issued.ownerSessionId || parentScope.worktree !== options.worktree) return undefined;
|
|
215
|
+
const access = createRuntimeAccess({ root: options.root, configDir: options.configDir, taskId: options.taskId, ownerSessionId: parentScope.ownerSessionId, runId: child.runId, domain: child.domain, role: child.role, worktree: child.worktree, grants: issued.grants, parentAssignmentId: issued.id, ...(options.signal ? { signal: options.signal } : {}) });
|
|
216
|
+
if (access) childKeys.push(access.assignmentId);
|
|
217
|
+
return access;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Durable reads under assignment scope: identity, grant and scope revalidate on every page. */
|
|
221
|
+
function artifactPage(access: RuntimeAccess | undefined, request: DeskRequest): Omit<DeskResponse, "id"> {
|
|
222
|
+
const ref = request.ref ?? "";
|
|
223
|
+
if (!access || !artifactIdentity(request, access.assignmentId)) return { ok: false, text: "Permission denied: no durable artifact access for this identity." };
|
|
224
|
+
if (!access.authorize("artifact.read", "artifact", ref)) return { ok: false, text: "Permission denied: artifact access was revoked or never granted." };
|
|
225
|
+
try {
|
|
226
|
+
return { ok: true, text: JSON.stringify(openOutputArtifact(access.artifactAccess, ref, request.offset ?? 0)) };
|
|
227
|
+
} catch {
|
|
228
|
+
return { ok: false, text: "Output artifact unavailable or unauthorized" };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
88
232
|
/** The host owns quotas, file identities and child lifetimes, outside model context. */
|
|
89
233
|
export async function delegationHost(options: PiRunOptions, run: ProcessRunner) {
|
|
90
234
|
const config = loadConfig();
|
|
@@ -95,11 +239,13 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
|
|
|
95
239
|
const depth = agent.depth ?? 1;
|
|
96
240
|
const quota = options.delegationQuota ?? { used: 0 };
|
|
97
241
|
const consults = canConsult(agent, config.workflow.consultExpert);
|
|
98
|
-
|
|
242
|
+
// Durable artifact reads appear only when the caller handed the host a live assignment access.
|
|
243
|
+
const artifactTool = options.runtimeAccess?.authorize("artifact.read") === true ? DURABLE_OUTPUT_TOOL : undefined;
|
|
244
|
+
const tools = [...new Set([...baseTools, "codemode", "tool_search", ...(artifactTool ? [artifactTool] : []), ...(canDelegate(agent, quota.used) ? [DELEGATE_TOOL] : []), ...(consults ? [EXPERT_TOOL] : {})])];
|
|
99
245
|
// A caller that passes its profile's grant (even an absent one, meaning every connected tool) wins over the role's.
|
|
100
246
|
const mcpTools = mcpGrant("mcpTools" in options ? options.mcpTools : agentProfile(config, agent.domain, agent.role).mcpTools, !editable);
|
|
101
247
|
const policy: AgentPolicy = { tools, mcpTools, readOnly: !editable, depth };
|
|
102
|
-
if (depth > 1) return
|
|
248
|
+
if (depth > 1) return childHost(options, policy, tools, mcpTools, artifactTool);
|
|
103
249
|
|
|
104
250
|
const address = options.env?.[DESK_ENV.address];
|
|
105
251
|
let desk = address ? deskSessionAt(address) : undefined;
|
|
@@ -135,6 +281,10 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
|
|
|
135
281
|
|
|
136
282
|
const childRun = async (brief: string, signal: AbortSignal): Promise<AgentRun> => {
|
|
137
283
|
const childId = randomUUID();
|
|
284
|
+
// The child's board and desk identity carries its parent's role, so a post addressed to the role reaches every worker of it while the child keeps its own cursors.
|
|
285
|
+
const childWorker = `${agent.domain}:${childId}`;
|
|
286
|
+
// Each child runs under its own assignment identity, issued within the parent's live scope.
|
|
287
|
+
const childAccess = options.runtimeAccess?.issueChild({ taskId: agent.taskId, ownerSessionId: options.runtimeAccess.ownerSessionId, runId: id, worktree: options.cwd }, { runId: childId, domain: agent.domain, role: agent.role, worktree: options.cwd });
|
|
138
288
|
const base: AgentRun = { runId: childId, parentRunId: id, depth: 2, taskId: agent.taskId, domain: agent.domain, role: agent.role, instruction: brief, stepInstruction: options.task, status: "running", output: "", attempts: 1, startedAt: new Date().toISOString(), ...(options.thinking ? { thinking: options.thinking } : {}) };
|
|
139
289
|
const edits = new EditLog(options.cwd);
|
|
140
290
|
const live = createLiveRun(base, { onUpdate: update }, edits);
|
|
@@ -153,14 +303,16 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
|
|
|
153
303
|
task: brief,
|
|
154
304
|
model,
|
|
155
305
|
thinking,
|
|
156
|
-
tools: [...new Set([...tools.filter((tool) => tool !== DELEGATE_TOOL && tool !== EXPERT_TOOL), ...(childDesk ? childDesk.tools().filter((tool) => !PEER_TOOLS.includes(tool)) : [])])],
|
|
306
|
+
tools: [...new Set([...tools.filter((tool) => tool !== DELEGATE_TOOL && tool !== EXPERT_TOOL && (tool !== DURABLE_OUTPUT_TOOL || !!childAccess)), ...(childDesk ? childDesk.tools().filter((tool) => !PEER_TOOLS.includes(tool)) : [])])],
|
|
157
307
|
mcpTools,
|
|
308
|
+
...(childAccess ? { runtimeAccess: childAccess } : {}),
|
|
158
309
|
signal,
|
|
159
310
|
delegationQuota: undefined,
|
|
160
311
|
onChildRun: undefined,
|
|
161
312
|
onEvent: live.onEvent,
|
|
162
|
-
|
|
163
|
-
|
|
313
|
+
// A child keeps the board but under its own identity, so its reads, pushes and acknowledgements never collide with its parent's or a same-role sibling's.
|
|
314
|
+
env: { ...options.env, ...(board ? { BOT_LOBBY_BOARD_FROM: childWorker } : {}), ...(childDesk ? childDesk.env(childWorker) : {}) },
|
|
315
|
+
onStart: (handle) => childDesk?.attach(childWorker, { runId: childId, steer: handle.steer, annotate: live.annotate }),
|
|
164
316
|
}, run);
|
|
165
317
|
};
|
|
166
318
|
let result = await attempt(route?.model ?? configured.model, route?.thinking ?? configured.thinking);
|
|
@@ -249,6 +401,7 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
|
|
|
249
401
|
parent.removeEventListener("abort", stop);
|
|
250
402
|
}
|
|
251
403
|
}
|
|
404
|
+
if (request.op === "artifact") return artifactPage(options.runtimeAccess, request);
|
|
252
405
|
if (request.op !== "delegate") return { ok: false, text: "Unknown delegation operation." };
|
|
253
406
|
const signal = childSignal();
|
|
254
407
|
signal.throwIfAborted();
|
|
@@ -274,7 +427,7 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
|
|
|
274
427
|
return {
|
|
275
428
|
tools: desk ? [...new Set([...tools, ...desk.tools()])] : tools,
|
|
276
429
|
mcpTools,
|
|
277
|
-
env: { ...options.env, ...(desk ? desk.env(parentDeskId) : {}), [AGENT_ENV]: JSON.stringify({ ...policy, tools: desk ? [...new Set([...tools, ...desk.tools()])] : tools }), [DELEGATION_ENV]: JSON.stringify({ address: server.address, token }) },
|
|
430
|
+
env: { ...options.env, ...(desk ? desk.env(parentDeskId) : {}), [AGENT_ENV]: JSON.stringify({ ...policy, tools: desk ? [...new Set([...tools, ...desk.tools()])] : tools }), [DELEGATION_ENV]: JSON.stringify({ address: server.address, token, ...(options.runtimeAccess ? { assignment: options.runtimeAccess.assignmentId } : {}) }) },
|
|
278
431
|
note: `${consults ? `When you are stuck on a hard step after a real attempt, or about to make a choice you are unsure of (a tricky algorithm, a subtle bug, an unfamiliar API), ${EXPERT_TOOL} asks a stronger model for advice, at most ${MAX_CONSULTS} times; it reads the repository and answers, you still do the work. ` : ""}${canDelegate(agent, quota.used) ? `Work directly when you can finish the brief yourself. Use ${DELEGATE_TOOL} only for useful, separable work with disjoint files, at most five children total; children cannot delegate further. Integrate and verify their work yourself. ` : ""}Use codemode for concise parallel calls, inspect failures, and return only useful evidence.${desk?.usesClaims ? " Before edit/write, claim_file; handover_file when finished. Release files before delegating or waiting." : ""}`,
|
|
279
432
|
onStart: (handle: Parameters<NonNullable<PiRunOptions["onStart"]>>[0]) => {
|
|
280
433
|
desk?.attach(parentDeskId, { runId: id, steer: handle.steer, annotate: () => {} });
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const OUTPUT_PREVIEW_CHARS = 4000;
|
|
6
|
+
export const OUTPUT_PAGE_BYTES = 4000;
|
|
7
|
+
|
|
8
|
+
/** Trusted harness context, never agent-supplied paths or an authorization snapshot. */
|
|
9
|
+
export interface OutputArtifactAccess {
|
|
10
|
+
repository: string;
|
|
11
|
+
worktree: string;
|
|
12
|
+
taskDir: string;
|
|
13
|
+
taskId: string;
|
|
14
|
+
callerKey: string;
|
|
15
|
+
/** Additional callers whose artifacts this access may open (the assignment's issued children). */
|
|
16
|
+
allowedCallerKeys?: readonly string[];
|
|
17
|
+
/** Records a reference once a page is opened from it, so reads stay attributable. */
|
|
18
|
+
onRead?: (ref: string) => void;
|
|
19
|
+
/** Must consult current task assignment and permissions, including on continuation. */
|
|
20
|
+
authorize: () => boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface OutputReport {
|
|
24
|
+
output: string;
|
|
25
|
+
status: string;
|
|
26
|
+
error?: string;
|
|
27
|
+
exitCode?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Scope {
|
|
31
|
+
repository: string;
|
|
32
|
+
worktree: string;
|
|
33
|
+
taskDir: string;
|
|
34
|
+
taskId: string;
|
|
35
|
+
callerKey: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Manifest extends Omit<OutputReport, "output"> {
|
|
39
|
+
scope: Scope;
|
|
40
|
+
bytes: number;
|
|
41
|
+
modified: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface OutputArtifactPage extends Omit<OutputReport, "output"> {
|
|
45
|
+
text: string;
|
|
46
|
+
nextOffset?: number;
|
|
47
|
+
bytes: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function denied(): never {
|
|
51
|
+
throw new Error("Output artifact unavailable or unauthorized");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reject symlinks at every component below the canonical repository root. */
|
|
55
|
+
function checkedDirectory(root: string, path: string): string {
|
|
56
|
+
const rel = relative(root, resolve(path));
|
|
57
|
+
if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`)) denied();
|
|
58
|
+
let current = root;
|
|
59
|
+
for (const part of rel.split(sep).filter(Boolean)) {
|
|
60
|
+
current = join(current, part);
|
|
61
|
+
const stat = lstatSync(current);
|
|
62
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) denied();
|
|
63
|
+
}
|
|
64
|
+
return current;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function scopeFor(access: OutputArtifactAccess): Scope {
|
|
68
|
+
if (!access.authorize() || !access.taskId || !access.callerKey) denied();
|
|
69
|
+
const repository = realpathSync(access.repository);
|
|
70
|
+
const worktree = realpathSync(access.worktree);
|
|
71
|
+
const taskDir = checkedDirectory(repository, access.taskDir);
|
|
72
|
+
return { repository, worktree, taskDir, taskId: access.taskId, callerKey: access.callerKey };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function artifactDirectory(scope: Scope, create = false): string {
|
|
76
|
+
const path = join(scope.taskDir, "outputs");
|
|
77
|
+
if (create) mkdirSync(path, { mode: 0o700 });
|
|
78
|
+
return checkedDirectory(scope.repository, path);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function openRegular(path: string): number {
|
|
82
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
83
|
+
const stat = fstatSync(fd);
|
|
84
|
+
if (stat.isFile() && stat.nlink === 1) return fd;
|
|
85
|
+
closeSync(fd);
|
|
86
|
+
return denied();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readManifest(path: string): Manifest {
|
|
90
|
+
const fd = openRegular(path);
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(readFileSync(fd, "utf8")) as Manifest;
|
|
93
|
+
} finally {
|
|
94
|
+
closeSync(fd);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function ensureArtifacts(scope: Scope): string {
|
|
99
|
+
try {
|
|
100
|
+
return artifactDirectory(scope, true);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
103
|
+
return artifactDirectory(scope);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function saveReport(dir: string, ref: string, scope: Scope, report: OutputReport): void {
|
|
108
|
+
const text = join(dir, `${ref}.txt`);
|
|
109
|
+
writeFileSync(text, report.output, { flag: "wx", mode: 0o600, flush: true });
|
|
110
|
+
try {
|
|
111
|
+
const stat = lstatSync(text);
|
|
112
|
+
const manifest: Manifest = { scope, status: report.status, error: report.error, exitCode: report.exitCode, bytes: stat.size, modified: stat.mtimeMs };
|
|
113
|
+
writeFileSync(join(dir, `${ref}.json`), JSON.stringify(manifest), { flag: "wx", mode: 0o600, flush: true });
|
|
114
|
+
} catch (error) {
|
|
115
|
+
unlinkSync(text);
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Pass only harness-owned report text, never a raw stream, tool transcript or thinking. Parse contracts before previewing. */
|
|
121
|
+
export function preserveOutputArtifact<T extends OutputReport>(access: OutputArtifactAccess, report: T): T & { artifact?: string } {
|
|
122
|
+
const scope = scopeFor(access);
|
|
123
|
+
if (report.output.length <= OUTPUT_PREVIEW_CHARS) return report;
|
|
124
|
+
const ref = `O-${randomUUID()}`;
|
|
125
|
+
saveReport(ensureArtifacts(scope), ref, scope, report);
|
|
126
|
+
const preview = `${report.output.slice(0, OUTPUT_PREVIEW_CHARS)}\n[Output preview; full report: ${ref}]`;
|
|
127
|
+
return { ...report, output: preview, artifact: ref };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readPage(fd: number, offset: number, size: number): { text: string; nextOffset?: number } {
|
|
131
|
+
const buffer = Buffer.alloc(OUTPUT_PAGE_BYTES + 1);
|
|
132
|
+
const count = readSync(fd, buffer, 0, buffer.length, offset);
|
|
133
|
+
if (count && offset && (buffer[0]! & 0xc0) === 0x80) denied();
|
|
134
|
+
let end = Math.min(count, OUTPUT_PAGE_BYTES);
|
|
135
|
+
while (end < count && (buffer[end]! & 0xc0) === 0x80) end -= 1;
|
|
136
|
+
const next = offset + end;
|
|
137
|
+
return { text: buffer.subarray(0, end).toString("utf8"), ...(next < size ? { nextOffset: next } : {}) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function scopedManifest(dir: string, ref: string, scope: Scope, callerKeys: readonly string[] = [scope.callerKey]): Manifest {
|
|
141
|
+
const manifest = readManifest(join(dir, `${ref}.json`));
|
|
142
|
+
const recorded = manifest.scope;
|
|
143
|
+
const sameScope = recorded.repository === scope.repository && recorded.worktree === scope.worktree && recorded.taskDir === scope.taskDir && recorded.taskId === scope.taskId;
|
|
144
|
+
if (!sameScope || !callerKeys.includes(recorded.callerKey)) denied();
|
|
145
|
+
if (typeof manifest.status !== "string" || !Number.isSafeInteger(manifest.bytes) || manifest.bytes < 0) denied();
|
|
146
|
+
if (manifest.error !== undefined && typeof manifest.error !== "string") denied();
|
|
147
|
+
if (manifest.exitCode !== undefined && !Number.isSafeInteger(manifest.exitCode)) denied();
|
|
148
|
+
return manifest;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Byte offsets are navigation only, not authorization. Each page rechecks current access and canonical paths. */
|
|
152
|
+
export function openOutputArtifact(access: OutputArtifactAccess, ref: string, offset = 0): OutputArtifactPage {
|
|
153
|
+
const scope = scopeFor(access);
|
|
154
|
+
if (!/^O-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(ref) || !Number.isSafeInteger(offset) || offset < 0) denied();
|
|
155
|
+
const dir = artifactDirectory(scope);
|
|
156
|
+
const manifest = scopedManifest(dir, ref, scope, [scope.callerKey, ...(access.allowedCallerKeys ?? [])]);
|
|
157
|
+
const fd = openRegular(join(dir, `${ref}.txt`));
|
|
158
|
+
try {
|
|
159
|
+
const stat = fstatSync(fd);
|
|
160
|
+
if (stat.size !== manifest.bytes || stat.mtimeMs !== manifest.modified || offset > stat.size) denied();
|
|
161
|
+
const page = { ...readPage(fd, offset, stat.size), status: manifest.status, error: manifest.error, exitCode: manifest.exitCode, bytes: stat.size };
|
|
162
|
+
recordRead(access, ref);
|
|
163
|
+
return page;
|
|
164
|
+
} finally {
|
|
165
|
+
closeSync(fd);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A failed bookkeeping note never fails a permitted read. */
|
|
170
|
+
function recordRead(access: OutputArtifactAccess, ref: string): void {
|
|
171
|
+
try {
|
|
172
|
+
access.onRead?.(ref);
|
|
173
|
+
} catch {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { pingTransition } from "./pi/notify.ts";
|
|
|
12
12
|
import { isSubagentProcess } from "./pi/quiet.ts";
|
|
13
13
|
import { registerDeskClient } from "./desk/client-extension.ts";
|
|
14
14
|
import { registerBoardClient } from "./desk/board-client.ts";
|
|
15
|
+
import { registerKnowledgeTools } from "./knowledge/catalog-tools.ts";
|
|
15
16
|
import { registerLobbyEvents } from "./lobby/runtime.ts";
|
|
16
17
|
import { registerOwner } from "./pi/owner.ts";
|
|
17
18
|
import { registerClassifier } from "./classifier/instance.ts";
|
|
@@ -55,6 +56,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
55
56
|
if (isSubagentProcess()) registerBoardClient(pi);
|
|
56
57
|
// Subagents look files up with the classifier (the engine allows the tool only while file hints are on).
|
|
57
58
|
if (isSubagentProcess()) registerClassifierTools(pi);
|
|
59
|
+
// Search/open over the derived knowledge catalogue; the engine grants it only where the run carries knowledge access.
|
|
60
|
+
registerKnowledgeTools(pi, CONFIG_DIR_NAME);
|
|
58
61
|
// A subagent given a tool budget (scouts) is warned, then refused, so it reports instead of searching on.
|
|
59
62
|
if (isSubagentProcess()) registerToolBudget(pi);
|
|
60
63
|
}
|