pi-better-subagents 0.4.2 → 0.5.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.
@@ -160,6 +160,8 @@ export function installTaskTools(pi: ExtensionAPI, options: {
160
160
  shellPath?: () => string | undefined;
161
161
  trustedSources: readonly string[];
162
162
  admitExtensionTool?: (name: string, input: unknown, sourcePath: string | undefined) => boolean;
163
+ /** Build the admitted bash definition from the confined operations (default: the SDK bash tool). */
164
+ bashDefinition?: (cwd: string, operations: BashOperations) => ReturnType<typeof createBashToolDefinition>;
163
165
  }) {
164
166
  const { controller } = options;
165
167
  const sourceKey = (path: string) => path.startsWith("<") ? path : canonicalizePath(path);
@@ -180,7 +182,7 @@ export function installTaskTools(pi: ExtensionAPI, options: {
180
182
  pi.registerTool(own(createReadToolDefinition(cwd, { operations: files.read })));
181
183
  pi.registerTool(own(createWriteToolDefinition(cwd, { operations: files.write })));
182
184
  pi.registerTool(own(createEditToolDefinition(cwd, { operations: files.edit })));
183
- pi.registerTool(own(createBashToolDefinition(cwd, { operations: bash })));
185
+ pi.registerTool(own(options.bashDefinition ? options.bashDefinition(cwd, bash) : createBashToolDefinition(cwd, { operations: bash })));
184
186
  };
185
187
  register(options.cwd);
186
188
  pi.on("user_bash", () => ({ operations: bash }));
package/task-guard.ts CHANGED
@@ -4,6 +4,10 @@ import { describeSandboxSupport } from "./shared-sandbox-core.ts";
4
4
  import { installTaskTools, TASK_BUILTINS } from "./shared-task-sandbox.ts";
5
5
  import { parseTaskPolicy } from "./task-policy.ts";
6
6
  import { SANDBOX_POLICY_CHANNEL, SANDBOX_POLICY_REQUEST_CHANNEL } from "./permission-policy.ts";
7
+ import { failureDispositionTool, intentBashDefinition } from "./child-incidents.ts";
8
+ import { DISPOSITION_TOOL } from "./incident-model.ts";
9
+
10
+ const TRUSTED_INLINE_SOURCE = "<inline:task-sandbox>";
7
11
 
8
12
  export default function taskGuard(pi: ExtensionAPI, input: unknown, fatal: (error: unknown) => never): void {
9
13
  const policy = parseTaskPolicy(input);
@@ -16,9 +20,15 @@ export default function taskGuard(pi: ExtensionAPI, input: unknown, fatal: (erro
16
20
  });
17
21
  const controller = Object.freeze({ requireLaunchPlan: () => plan });
18
22
  let shellPath: string | undefined;
23
+ let currentCwd = process.cwd();
19
24
  const boundary = installTaskTools(pi, { controller, cwd: process.cwd(), shellPath: () => shellPath,
20
- trustedSources: ["<inline:task-sandbox>"],
25
+ trustedSources: [TRUSTED_INLINE_SOURCE],
26
+ // Structured command intent (#315): validated before the confined command runs.
27
+ bashDefinition: (cwd, operations) => intentBashDefinition(cwd, operations) as any,
28
+ // The disposition tool performs no file or command I/O; only this inline registration is admitted.
29
+ admitExtensionTool: (name, _input, sourcePath) => name === DISPOSITION_TOOL && sourcePath === TRUSTED_INLINE_SOURCE,
21
30
  });
31
+ pi.registerTool(failureDispositionTool(() => currentCwd) as any);
22
32
  const profile = Object.freeze({ enabled: true, ...policy.permissions });
23
33
  const status = Object.freeze({
24
34
  state: "enabled", projectRoot: policy.root, writableRoot: policy.root,
@@ -31,12 +41,14 @@ export default function taskGuard(pi: ExtensionAPI, input: unknown, fatal: (erro
31
41
  pi.on("session_start", (_event, ctx) => {
32
42
  try {
33
43
  shellPath = SettingsManager.create(ctx.cwd, policy.agentDir, { projectTrusted: false }).getShellPath();
44
+ currentCwd = ctx.cwd;
34
45
  boundary.register(ctx.cwd);
35
46
  const selected = policy.tools.filter((name) => (TASK_BUILTINS as readonly string[]).includes(name));
36
47
  boundary.assertInstalled(selected);
37
- pi.setActiveTools(selected);
48
+ const activated = selected.length ? [...selected, DISPOSITION_TOOL] : selected;
49
+ pi.setActiveTools(activated);
38
50
  const active = new Set(pi.getActiveTools());
39
- if (active.size !== selected.length || selected.some((name) => !active.has(name))) {
51
+ if (active.size !== activated.length || activated.some((name) => !active.has(name))) {
40
52
  throw new Error("SDK did not activate the guarded tool set; refusing to start the task.");
41
53
  }
42
54
  pi.events.emit(SANDBOX_POLICY_CHANNEL, status);
package/tools.ts CHANGED
@@ -15,19 +15,33 @@
15
15
  */
16
16
 
17
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
- import { readMeta, listMetas, effectiveStatus, isFinalResultStatus, type RunMeta, type RunStatus } from "./registry.ts";
19
- import { parseRun, tailLog, formatSubagentOutputBody } from "./parse.ts";
20
- import { buildSubagentResultText } from "./finalization.ts";
21
- import { failureSummary, prependFailureSummary } from "./failures.ts";
22
- import { formatOrphanedResult } from "./lifecycle.ts";
18
+ import { readMeta, listRunRecords, effectiveStatus, isFinalResultStatus, type RunMeta, type RunStatus } from "./registry.ts";
19
+ import { buildSubagentResultPayload } from "./finalization.ts";
23
20
  import { stopRun } from "./stop.ts";
24
- import { fmtElapsed, fmtSpend } from "./widget.ts";
25
21
  import {
26
22
  SUBAGENT_LIST_DEFAULT_LIMIT,
27
23
  SUBAGENT_LIST_MAX_LIMIT,
28
24
  SUBAGENT_LIST_STATUSES,
29
- buildSubagentList,
25
+ collectSubagentList,
26
+ formatSubagentListRow,
30
27
  } from "./list.ts";
28
+ import {
29
+ assembleOrphanedResult,
30
+ assembleRunningResult,
31
+ assembleSubagentListPayload,
32
+ assembleSubagentOutput,
33
+ assembleUnreadableMetadata,
34
+ listIncidentCount,
35
+ formatIncidentLabel,
36
+ listRevisions,
37
+ listScopeKey,
38
+ requestScopeKey,
39
+ resolveActiveOrigin,
40
+ resolveRunAccess,
41
+ runInListScope,
42
+ unknownRunError,
43
+ type SubagentToolSession,
44
+ } from "./output-payload.ts";
31
45
  import {
32
46
  extractChildEventFactsFromLog,
33
47
  loadHealthThresholdsFromConfig,
@@ -35,7 +49,6 @@ import {
35
49
  type HealthObservation,
36
50
  } from "./health-observation.ts";
37
51
  import {
38
- appendHealthDiagnostic,
39
52
  formatHealthDiagnosticLine,
40
53
  statusThemeColor,
41
54
  truncateToVisibleWidth,
@@ -222,25 +235,49 @@ function displayThemeColor(status: string): string {
222
235
  type ToolDefinition = Parameters<ExtensionAPI["registerTool"]>[0];
223
236
 
224
237
  // ---- subagent_list --------------------------------------------------------
225
- export function subagentListTool(Type: TypeModule): ToolDefinition {
238
+ export type { SubagentToolSession };
239
+
240
+ /** The session provider bound to this tool call's host context. */
241
+ function forCall(session: SubagentToolSession, ctx: unknown): SubagentToolSession {
242
+ const provider = session.getActiveOrigin;
243
+ if (typeof provider !== "function") return session;
244
+ return { getActiveOrigin: () => provider(ctx) };
245
+ }
246
+
247
+ export function subagentListTool(Type: TypeModule, baseSession: SubagentToolSession = {}): ToolDefinition {
226
248
  return {
227
249
  name: "subagent_list",
228
250
  label: "List Subagents",
229
251
  description:
230
- `List background subagent runs with status and metadata. Non-blocking. ` +
231
- `Default: this parent process only, newest first, limit ${SUBAGENT_LIST_DEFAULT_LIMIT}. ` +
232
- `Pass all:true for machine-global; limit is clamped to max ${SUBAGENT_LIST_MAX_LIMIT}.`,
252
+ `List background subagent runs with compact status rows. Non-blocking. ` +
253
+ `Default: current session, newest first, limit ${SUBAGENT_LIST_DEFAULT_LIMIT} (1 KiB page). ` +
254
+ `Pass all:true for machine-global; limit is clamped to max ${SUBAGENT_LIST_MAX_LIMIT}. ` +
255
+ `Pass cursor to continue. Incident counts are compact; full failure text lives on subagent_result/output.`,
233
256
  promptSnippet: "List background subagent runs and their status",
234
257
  parameters: Type.Object({
235
- all: Type.Optional(Type.Boolean({ description: "If true, list every run on this machine. Default false = only runs spawned by this pi process." })),
258
+ all: Type.Optional(Type.Boolean({ description: "If true, list every run on this machine. Default false = current session only." })),
236
259
  limit: Type.Optional(Type.Number({ description: `Maximum rows to display (default ${SUBAGENT_LIST_DEFAULT_LIMIT}, max ${SUBAGENT_LIST_MAX_LIMIT}; larger values are clamped).` })),
237
260
  status: Type.Optional(Type.Array(Type.String(), { description: `Effective statuses to include: ${SUBAGENT_LIST_STATUSES.join(", ")}.` })),
261
+ cursor: Type.Optional(Type.String({ description: "Caller-owned page or status cursor from a previous list response." })),
262
+ maxBytes: Type.Optional(Type.Number({ description: "UTF-8 byte budget for this page (default 1 KiB, max 4 KiB)." })),
238
263
  }),
239
- async execute(_toolCallId: string, params: unknown) {
240
- const p = (params ?? {}) as { all?: boolean; limit?: number; status?: string[] | string };
264
+ async execute(_toolCallId: string, params: unknown, _signal?: unknown, _onUpdate?: unknown, ctx?: unknown) {
265
+ const session = forCall(baseSession, ctx);
266
+ const p = (params ?? {}) as {
267
+ all?: boolean; limit?: number; status?: string[] | string; cursor?: string; maxBytes?: number;
268
+ };
241
269
  const now = Date.now();
242
- const metas = listMetas();
243
- // Cache observations per id so usage + health share one parse where needed.
270
+ const index = listRunRecords();
271
+ const metas = index.metas;
272
+ const parentPid = process.pid;
273
+ const resolved = resolveActiveOrigin(session);
274
+ const origin = resolved.origin;
275
+ const sessionAvailable = !(resolved.wired && !resolved.available);
276
+ const scopeKey = listScopeKey(p.all === true, origin, parentPid, sessionAvailable);
277
+ let sessionWarning: string | undefined;
278
+ if (p.all !== true && !sessionAvailable) {
279
+ sessionWarning = `Session identity unavailable; ownership of ${metas.length} run(s) cannot be verified, so none are listed. Pass all:true for machine-global.`;
280
+ }
244
281
  const healthCache = new Map<string, HealthObservation>();
245
282
  const healthById = (id: string) => {
246
283
  const hit = healthCache.get(id);
@@ -251,117 +288,145 @@ export function subagentListTool(Type: TypeModule): ToolDefinition {
251
288
  healthCache.set(id, obs);
252
289
  return obs;
253
290
  };
254
- return text(buildSubagentList({
291
+ const inScope = (meta: RunMeta) => runInListScope(
292
+ meta,
293
+ { all: p.all === true },
294
+ origin,
295
+ parentPid,
296
+ sessionAvailable,
297
+ );
298
+ const collected = collectSubagentList({
255
299
  metas,
256
300
  params: p,
257
- parentPid: process.pid,
301
+ parentPid,
258
302
  now,
259
303
  statusOf: effectiveStatus,
260
- usageById: (id: string) => parseRun(id).usage,
261
304
  healthById,
262
- failureById: (id: string) => {
263
- const meta = metas.find((m) => m.id === id);
264
- return meta ? failureSummary(id, meta.cwd, meta.status !== "running" && meta.status !== "orphaned") : "";
305
+ inScope,
306
+ });
307
+ const items = collected.items as Array<{ meta: RunMeta; status: string }>;
308
+ const incidents = new Map(items.map((row) => [row.meta.id, listIncidentCount(
309
+ row.meta.id,
310
+ row.meta.cwd,
311
+ row.meta.status !== "running" && row.meta.status !== "orphaned",
312
+ )] as const));
313
+ const revisions = listRevisions(
314
+ items.map((row) => ({ id: row.meta.id, status: String(row.status), meta: row.meta })),
315
+ items.map((row) => incidents.get(row.meta.id)?.revision ?? ""),
316
+ );
317
+ const statusesKey = Array.isArray(p.status) ? [...p.status].map(String).sort().join(",") : String(p.status ?? "");
318
+ const incidentRuns = items.filter((row) => (incidents.get(row.meta.id)?.count ?? 0) > 0).length;
319
+ return text(assembleSubagentListPayload({
320
+ warnings: collected.warnings,
321
+ items,
322
+ render: (row) => {
323
+ const incident = incidents.get(row.meta.id);
324
+ return formatSubagentListRow(row.meta, {
325
+ status: row.status,
326
+ now,
327
+ health: healthById(row.meta.id),
328
+ failure: formatIncidentLabel(incident?.count ?? 0, incident?.actionRequired ?? 0),
329
+ });
265
330
  },
331
+ limit: Math.max(1, collected.limit),
332
+ cursor: p.cursor,
333
+ maxBytes: p.maxBytes,
334
+ contentRevision: revisions.contentRevision,
335
+ failureRevision: revisions.failureRevision,
336
+ scopeKey,
337
+ statusesKey,
338
+ sessionWarning,
339
+ unreadable: index.unreadable,
340
+ indexError: index.indexError,
341
+ incidentRuns,
266
342
  }));
267
343
  },
268
344
  } as ToolDefinition;
269
345
  }
270
346
 
271
347
  // ---- subagent_output ------------------------------------------------------
272
- export function subagentOutputTool(Type: TypeModule): ToolDefinition {
348
+ export function subagentOutputTool(Type: TypeModule, baseSession: SubagentToolSession = {}): ToolDefinition {
273
349
  return {
274
350
  name: "subagent_output",
275
351
  label: "Subagent Output",
276
352
  description:
277
- "Tail the live output of a subagent run. Non-blocking: returns whatever exists right now and " +
278
- "returns immediately whether or not the run has finished. Never waits.",
353
+ "Read a subagent run's current output without waiting. Default is a 1 KiB / 10-line excerpt of the current session. " +
354
+ "Pass all:true to read a foreign-session id. Pass mode=raw for retained-log pages (16 KiB default, 64 KiB cap). " +
355
+ "Pass cursor to continue or to poll for failure-only changes. Missing or unreadable logs are reported as gaps, not as empty healthy output.",
279
356
  promptSnippet: "Peek at a subagent's current output without waiting",
280
357
  promptGuidelines: [
281
358
  "Use subagent_output only when the user explicitly asks how a run is progressing. It never waits — do not call it in a loop.",
359
+ "Do not poll unchanged output. If the response says no new evidence since a cursor, stop.",
282
360
  ],
283
361
  parameters: Type.Object({
284
362
  id: Type.String({ description: "Run id from subagent_spawn." }),
285
- tail_lines: Type.Optional(Type.Number({ description: "How many trailing lines to show (default 40)." })),
363
+ tail_lines: Type.Optional(Type.Number({ description: "Deprecated alias for lines." })),
364
+ lines: Type.Optional(Type.Number({ description: "Max lines in the default excerpt (default 10)." })),
365
+ cursor: Type.Optional(Type.String({ description: "Caller-owned page or status cursor from a previous response." })),
366
+ maxBytes: Type.Optional(Type.Number({ description: "UTF-8 byte budget for this page (default 1 KiB, max 4 KiB; raw default 16 KiB, max 64 KiB)." })),
367
+ mode: Type.Optional(Type.String({ description: "raw = page retained log bytes. Default is the bounded assembled excerpt." })),
368
+ all: Type.Optional(Type.Boolean({ description: "If true, allow a foreign-session id. Default is current session only." })),
286
369
  }),
287
- async execute(_id: string, params: unknown) {
288
- const p = params as { id: string; tail_lines?: number };
289
- const meta = readMeta(p.id);
290
- if (!meta) throw new Error(`Unknown run id: ${p.id}`);
291
- const st = effectiveStatus(meta);
292
- const r = parseRun(p.id);
293
- const el = fmtElapsed((meta.endedAt ?? Date.now()) - meta.startedAt);
294
- const spend = fmtSpend(r.usage);
295
- const head = `[${p.id} · ${st} · ${el}${spend ? ` · ${spend}` : ""}]`;
296
- const tools = r.toolCalls.length ? `\ntools used: ${r.toolCalls.join(", ")}` : "";
297
- const raw = tailLog(p.id, p.tail_lines ?? 40);
298
- const body = formatSubagentOutputBody(
299
- head,
300
- tools,
301
- r.finalText || r.lastActivity || undefined,
302
- raw,
303
- r.diagnostics,
304
- );
305
- // Health diagnostics for orphaned/lost/degraded only (#67). Healthy/quiet
306
- // stays on today's body. Independent of meta.callback.
307
- const healthLine = formatHealthDiagnosticLine(observeMetaHealth(meta));
308
- return text(prependFailureSummary(appendHealthDiagnostic(body, healthLine), failureSummary(p.id, meta.cwd, st !== "running" && st !== "orphaned")));
370
+ async execute(_id: string, params: unknown, _signal?: unknown, _onUpdate?: unknown, ctx?: unknown) {
371
+ const session = forCall(baseSession, ctx);
372
+ const p = params as { id: string; cursor?: string; maxBytes?: number; mode?: string; all?: boolean; lines?: number; tail_lines?: number };
373
+ const access = resolveRunAccess(p.id, p, session);
374
+ if (access.kind === "missing") throw unknownRunError(p.id);
375
+ if (access.kind === "unreadable") return text(assembleUnreadableMetadata(p.id, access.detail, p));
376
+ if (access.kind === "denied") return text(access.payload);
377
+ const scopeKey = requestScopeKey(p, session);
378
+ const healthLine = formatHealthDiagnosticLine(observeMetaHealth(access.meta));
379
+ return text(assembleSubagentOutput(p.id, access.meta, p, healthLine, scopeKey));
309
380
  },
310
381
  } as ToolDefinition;
311
382
  }
312
383
 
313
384
  // ---- subagent_result ------------------------------------------------------
314
- export function subagentResultTool(Type: TypeModule): ToolDefinition {
385
+ export function subagentResultTool(Type: TypeModule, baseSession: SubagentToolSession = {}): ToolDefinition {
315
386
  return {
316
387
  name: "subagent_result",
317
388
  label: "Subagent Result",
318
389
  description:
319
- "Read a subagent's final output if it has finished. NEVER waits: if the run is still going it " +
320
- "says so and returns immediately.",
390
+ "Read a subagent's final output if it has finished. NEVER waits. Default current session. Ordinary answers are preserved verbatim in a 2 KiB UTF-8 page (max 8 KiB); pass cursor to reconstruct the rest. Pass all:true for a foreign-session id. mode=raw pages retained log bytes (16 KiB default, 64 KiB cap). Failures and exceptional lifecycle facts come before progress. No tool-name histories or default cost lines.",
321
391
  promptSnippet: "Read a finished subagent's final result (never waits)",
322
392
  promptGuidelines: [
323
393
  "Use subagent_result to collect a finished run's output. If it reports the run is still going, stop — do not poll; you'll be notified when it finishes.",
394
+ "If the response includes nextCursor, call again with that cursor to read the rest of the answer. Do not summarize away unread pages.",
324
395
  ],
325
396
  parameters: Type.Object({
326
397
  id: Type.String({ description: "Run id from subagent_spawn." }),
398
+ cursor: Type.Optional(Type.String({ description: "Caller-owned answer page or status cursor from a previous response." })),
399
+ maxBytes: Type.Optional(Type.Number({ description: "UTF-8 byte budget for this page (default 2 KiB, max 8 KiB)." })),
400
+ mode: Type.Optional(Type.String({ description: "raw = page retained log bytes (16 KiB default, 64 KiB cap). Default is the bounded assembled result." })),
401
+ all: Type.Optional(Type.Boolean({ description: "If true, allow a foreign-session id. Default is current session only." })),
327
402
  }),
328
403
  renderResult(result: unknown, options: unknown, theme: unknown) {
329
404
  return renderSubagentResultDisplay(result, options, theme);
330
405
  },
331
- async execute(_id: string, params: unknown) {
332
- const p = params as { id: string };
333
- const meta = readMeta(p.id);
334
- if (!meta) throw new Error(`Unknown run id: ${p.id}`);
406
+ async execute(_id: string, params: unknown, _signal?: unknown, _onUpdate?: unknown, ctx?: unknown) {
407
+ const session = forCall(baseSession, ctx);
408
+ const p = params as { id: string; cursor?: string; maxBytes?: number; mode?: string; all?: boolean };
409
+ const access = resolveRunAccess(p.id, p, session);
410
+ if (access.kind === "missing") throw unknownRunError(p.id);
411
+ if (access.kind === "unreadable") {
412
+ return subagentResultText(assembleUnreadableMetadata(p.id, access.detail, p));
413
+ }
414
+ if (access.kind === "denied") return subagentResultText(access.payload);
415
+ const meta = access.meta;
416
+ const scopeKey = requestScopeKey(p, session);
335
417
  const st = effectiveStatus(meta);
418
+ const healthLine = formatHealthDiagnosticLine(observeMetaHealth(meta));
336
419
  if (!isFinalResultStatus(st)) {
337
420
  if (st === "orphaned") {
338
- // Non-terminal: supervision is broken but related process-
339
- // group work may still be alive — never present this as a
340
- // final result. Surface best-CURRENT artifacts (#65) plus
341
- // health diagnostic (#67).
342
- const r = parseRun(p.id);
343
- const el = fmtElapsed((meta.endedAt ?? Date.now()) - meta.startedAt);
344
- const spend = fmtSpend(r.usage);
345
- const tools = r.toolCalls.length ? ` · tools: ${r.toolCalls.join(", ")}` : "";
346
- const head = `[${p.id} · orphaned · ${el}${spend ? ` · ${spend}` : ""}${tools}]`;
347
- const rawTail = tailLog(p.id, 40);
348
- const body = `${head}\n${formatOrphanedResult(r, rawTail)}`;
349
- const healthLine = formatHealthDiagnosticLine(observeMetaHealth(meta));
350
- return subagentResultText(prependFailureSummary(appendHealthDiagnostic(body, healthLine), failureSummary(p.id, meta.cwd)));
421
+ return subagentResultText(assembleOrphanedResult(p.id, meta, healthLine, p, scopeKey));
351
422
  }
352
- return subagentResultText(prependFailureSummary(`Run ${p.id} is still running — no result yet. You'll be notified when it finishes; don't poll.`, failureSummary(p.id, meta.cwd)));
423
+ return subagentResultText(assembleRunningResult(p.id, meta, p, scopeKey));
353
424
  }
354
- // Lifecycle-aware body (complete-stream authority + diagnostics).
355
- // Lost runs go through formatLostResult inside formatSubagentResult (#65).
356
- const body = buildSubagentResultText(p.id);
425
+ const body = buildSubagentResultPayload(p.id, p, healthLine, scopeKey);
357
426
  if (body === null) {
358
- // Defensive: status race between effectiveStatus and body assembly.
359
- return subagentResultText(`Run ${p.id} is still running — no result yet. You'll be notified when it finishes; don't poll.`);
427
+ return subagentResultText(assembleRunningResult(p.id, meta, p, scopeKey));
360
428
  }
361
- // Append degraded/lost health facts when present; completed/failed
362
- // happy paths stay quiet when observation is non-actionable.
363
- const healthLine = formatHealthDiagnosticLine(observeMetaHealth(meta));
364
- return subagentResultText(prependFailureSummary(appendHealthDiagnostic(body, healthLine), failureSummary(p.id, meta.cwd, true)));
429
+ return subagentResultText(body);
365
430
  },
366
431
  } as ToolDefinition;
367
432
  }