@a-t-h-i/bot-lobby 0.6.59 → 0.6.61

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.
@@ -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 { startDeskServer } from "../desk/ipc.ts";
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
- const tools = [...new Set([...baseTools, "codemode", "tool_search", ...(canDelegate(agent, quota.used) ? [DELEGATE_TOOL] : []), ...(consults ? [EXPERT_TOOL] : [])])];
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 { tools, mcpTools, env: { ...options.env, [AGENT_ENV]: JSON.stringify(policy), [DELEGATION_ENV]: "" }, 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.", close: async () => {}, onStart: options.onStart, children: [] as AgentRun[] };
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
- env: { ...options.env, ...(childDesk ? childDesk.env(childId) : {}) },
163
- onStart: (handle) => childDesk?.attach(childId, { runId: childId, steer: handle.steer, annotate: live.annotate }),
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
  }