@bermudi/pi-delegate 0.1.15 → 0.1.17

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/agents.ts CHANGED
@@ -120,7 +120,7 @@ export const BUILTIN_AGENT_CONFIGS: Readonly<Record<string, AgentConfig>> = {
120
120
  [DEFAULT_AGENT_NAME]: {
121
121
  name: DEFAULT_AGENT_NAME,
122
122
  description:
123
- "Mirror the live parent model, thinking level, native tools, and base prompt.",
123
+ "General-purpose subagent mirroring the live parent model, thinking level, native tools, and base prompt. Prefer it for general work; pick a specialist only when its role, tool limits, or scratch workspace genuinely fit.",
124
124
  tools: DEFAULT_TOOLS,
125
125
  systemPrompt: "",
126
126
  builtin: true,
@@ -151,7 +151,7 @@ export const BUILTIN_AGENT_CONFIGS: Readonly<Record<string, AgentConfig>> = {
151
151
  systemPrompt:
152
152
  "Review the current snapshot for correctness, regressions, security problems, and missing tests. Do not modify the source project. Run focused checks when useful. Report actionable findings ordered by severity, with concrete paths and locations. If there are no material findings, say so plainly; do not invent issues or merely summarize the implementation.",
153
153
  builtin: true,
154
- workspace: "scratch",
154
+ workspace: "shared",
155
155
  },
156
156
  };
157
157
 
package/constants.ts CHANGED
@@ -59,3 +59,13 @@ export const VALID_THINKING_LEVELS = [
59
59
  * `Set<VALID_THINKING_LEVELS[number]>`) so the `.has(string)` call sites in
60
60
  * task-resolution.ts and agents.ts stay ergonomic under strict typing. */
61
61
  export const VALID_THINKING: Set<string> = new Set(VALID_THINKING_LEVELS);
62
+
63
+ /** Session-control actions: RPC against the session pool, not work. Callers
64
+ * spell them at the TOP level (`sessionAction`, one action per call — #32).
65
+ * This predicate remains the one shared spelling of the control set for the
66
+ * session-mode intent check and validator in schema.ts and the pipeline
67
+ * guards over internally bridged tasks (task-resolution.ts). Per-action
68
+ * behavior lives at the branch sites (e.g. lifecycle applySessionAction). */
69
+ export function isSessionControlAction(action: unknown): boolean {
70
+ return action === "close" || action === "list";
71
+ }
package/delegate.ts CHANGED
@@ -3,6 +3,7 @@ export { default } from "./extension.ts";
3
3
  export type {
4
4
  AgentConfig,
5
5
  SessionAction,
6
+ DispatchableTask,
6
7
  WorkspaceMode,
7
8
  TicketAction,
8
9
  DelegateArguments,
package/dispatch.ts CHANGED
@@ -48,6 +48,7 @@ import type {
48
48
  DelegateToolResult,
49
49
  ParentAgentDefaults,
50
50
  ResolvedTask,
51
+ DispatchableTask,
51
52
  TaskDef,
52
53
  TaskProgress,
53
54
  TaskResult,
@@ -58,7 +59,7 @@ const UNSAFE_SHARED_WRITES_WARNING =
58
59
  "UNSAFE SHARED WRITES ENABLED: shared-write admission is bypassed. Delegate provides no isolation or rollback.";
59
60
 
60
61
  interface ActiveSyncDispatch {
61
- tasks: TaskDef[];
62
+ tasks: DispatchableTask[];
62
63
  resolved: ResolvedTask[];
63
64
  }
64
65
 
@@ -157,7 +158,7 @@ export function makeFireUpdater(
157
158
  export interface AsyncDispatchInput {
158
159
  pi: ExtensionAPI;
159
160
  ctx: DelegateToolCtx;
160
- tasks: TaskDef[];
161
+ tasks: DispatchableTask[];
161
162
  resolved: ResolvedTask[];
162
163
  progress: TaskProgress[];
163
164
  parentModelId: string | undefined;
@@ -169,7 +170,7 @@ export interface AsyncDispatchInput {
169
170
  /** Inputs needed by the sync (blocking) dispatch path. */
170
171
  export interface SyncDispatchInput {
171
172
  ctx: DelegateToolCtx;
172
- tasks: TaskDef[];
173
+ tasks: DispatchableTask[];
173
174
  resolved: ResolvedTask[];
174
175
  progress: TaskProgress[];
175
176
  parentModelId: string | undefined;
@@ -193,7 +194,7 @@ export interface DelegateDispatchInput {
193
194
  callSpan?: CallSpan;
194
195
  }
195
196
 
196
- function taskReference(task: TaskDef, index: number): string {
197
+ function taskReference(task: DispatchableTask, index: number): string {
197
198
  return `Task ${index + 1}${task.id ? `#${task.id}` : ""}`;
198
199
  }
199
200
 
@@ -207,7 +208,7 @@ function asAdmissionWriter(task: ResolvedTask): ResolvedTask {
207
208
  }
208
209
 
209
210
  function sharedWriteRejection(
210
- tasks: TaskDef[],
211
+ tasks: DispatchableTask[],
211
212
  parentModelId: string | undefined,
212
213
  conflicts: SharedWriteConflict[],
213
214
  references: readonly string[] = tasks.map(taskReference),
@@ -236,7 +237,7 @@ function sharedWriteRejection(
236
237
  }
237
238
 
238
239
  function sharedWriteSafetyFailure(
239
- tasks: TaskDef[],
240
+ tasks: DispatchableTask[],
240
241
  parentModelId: string | undefined,
241
242
  error: unknown,
242
243
  ): DelegateToolResult {
@@ -289,13 +290,26 @@ export async function dispatchDelegate(
289
290
  return validationError;
290
291
  }
291
292
 
292
- const resolved = resolveTasks(
293
+ const resolveResult = resolveTasks(
293
294
  tasks,
294
295
  ctx,
295
296
  agents,
296
297
  parentDefaults,
297
298
  dispatchConfig,
298
299
  );
300
+ if (resolveResult.error !== undefined) {
301
+ callSpan?.finish({
302
+ status: "failed",
303
+ totalTokens: 0,
304
+ totalCost: 0,
305
+ wallMs: Date.now() - callSpan.startedAt,
306
+ });
307
+ return {
308
+ content: [{ type: "text", text: resolveResult.error }],
309
+ details: { tasks, results: [], progress: [], parentModel: parentModelId },
310
+ };
311
+ }
312
+ const resolved = resolveResult.tasks;
299
313
  if (params.async && resolved.some((task) => task.workspace === "isolated")) {
300
314
  callSpan?.finish({
301
315
  status: "failed",
package/extension.ts CHANGED
@@ -44,7 +44,11 @@ import {
44
44
  prepareTelemetryForSession,
45
45
  sealTelemetryWrites,
46
46
  } from "./telemetry.ts";
47
- import type { DelegateArguments, DelegateDetails } from "./types.ts";
47
+ import type {
48
+ DelegateArguments,
49
+ DelegateDetails,
50
+ DispatchableTask,
51
+ } from "./types.ts";
48
52
 
49
53
  const DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS = 10_000;
50
54
  let shutdownDrainTimeoutMs = DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS;
@@ -52,6 +56,21 @@ let shutdownDrainTimeoutMs = DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS;
52
56
  type ShutdownDrainResult =
53
57
  { drained: true; failures: unknown[] } | { drained: false; failures: [] };
54
58
 
59
+ /** Bridge the promoted top-level session RPC (`sessionAction` + `sessionId`)
60
+ * to the internal single-entry batch the runner executes. Internal only — the
61
+ * public task schema has no `sessionAction`; validation guarantees close
62
+ * carries a sessionId by this point. `list` never carries a sessionId: it does
63
+ * not target a specific session, and attaching one would make validateTasks
64
+ * run busy/quarantine checks that can fail the list call. */
65
+ function bridgeSessionControlTask(params: DelegateArguments): DispatchableTask {
66
+ return {
67
+ ...(params.sessionAction === "close" && params.sessionId
68
+ ? { sessionId: params.sessionId }
69
+ : {}),
70
+ sessionAction: params.sessionAction,
71
+ };
72
+ }
73
+
55
74
  /** Wait for shutdown workers without allowing a stuck provider/tool to hold
56
75
  * Pi's reload or exit hostage. The allSettled promise is intentionally left
57
76
  * attached after timeout so a late rejection cannot become unhandled. */
@@ -160,6 +179,11 @@ export default function delegateExtension(pi: ExtensionAPI): void {
160
179
  let mode: string;
161
180
  if (params.ticketAction) {
162
181
  mode = params.ticketAction;
182
+ } else if (params.sessionAction) {
183
+ // Session RPC is its own mode, not a task shape — label it before the
184
+ // tasks-based branches so close/list spans stop misreporting as
185
+ // "sync" with a zero task count.
186
+ mode = "session";
163
187
  } else if (tasks.length === 0) {
164
188
  mode = "manual";
165
189
  } else if (params.async) {
@@ -173,6 +197,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
173
197
  mode,
174
198
  taskCount,
175
199
  parentSessionFile,
200
+ parentCwd: ctx.cwd,
176
201
  });
177
202
 
178
203
  function failCall(): void {
@@ -246,6 +271,35 @@ export default function delegateExtension(pi: ExtensionAPI): void {
246
271
  // silently swap the agent roster.
247
272
  const agents = discoverAgents(ctx.cwd);
248
273
 
274
+ // ── Session RPC (top-level sessionAction, #32 promotion) ─────────
275
+ // Session control is no longer spelled as a task, but it still runs
276
+ // through the one runner: bridge it to an internal single-entry batch so
277
+ // busy-index validation, per-session locking (lifecycle's literal
278
+ // per-action branches), progress rows, and result formatting all keep
279
+ // their existing behavior. Never async — validation rejects that.
280
+ // Must precede the help short-circuit: session RPC carries no tasks and
281
+ // must not be mistaken for an empty help request.
282
+ if (params.sessionAction) {
283
+ invalidateHostDepsCache();
284
+ // Keep the footer-status pipeline in step exactly as a normal
285
+ // dispatch would (deduped no-op when nothing is running).
286
+ syncDelegateStatus(ctx);
287
+ return await dispatchDelegate({
288
+ pi,
289
+ params: { ...params, tasks: [bridgeSessionControlTask(params)] },
290
+ ctx,
291
+ agents,
292
+ parentModelId,
293
+ parentDefaults: {
294
+ thinking: pi.getThinkingLevel(),
295
+ tools: pi.getActiveTools(),
296
+ },
297
+ signal,
298
+ onUpdate,
299
+ callSpan,
300
+ });
301
+ }
302
+
249
303
  // ── Help mode ─────────────────────────────────────────────────
250
304
  if (!tasks.length) {
251
305
  succeedCall();
package/lifecycle.ts CHANGED
@@ -142,9 +142,18 @@ function scratchSetupFailureResult(
142
142
  if (signalAborted) {
143
143
  message = "Aborted";
144
144
  failureKind = "cancelled";
145
- } else if (error instanceof ScratchDeadlineError) {
146
- message = formatDeadlineExceededError(task.deadlineMs ?? 0);
147
- failureKind = "deadline_exceeded";
145
+ } else {
146
+ if (error instanceof ScratchDeadlineError) {
147
+ message = formatDeadlineExceededError(task.deadlineMs ?? 0);
148
+ failureKind = "deadline_exceeded";
149
+ }
150
+ // Every non-aborted setup failure names its remedy in one place, so the
151
+ // paths (platform guard, pre-check, reflink, worktree, deadline) cannot
152
+ // drift. The symlink message already carries remedy text naming
153
+ // workspace "shared" — skip the append there to avoid saying it twice.
154
+ if (!message.includes('workspace "shared"')) {
155
+ message = `${message} — to retry without scratch containment, use workspace: "shared".`;
156
+ }
148
157
  }
149
158
  return {
150
159
  ...failTask(task, message, undefined, failureKind),
@@ -433,9 +442,10 @@ function recordTaskOutcome(
433
442
  * that model, so same-model retry is pointless. Distinguished from a bare
434
443
  * transient 429 (per-minute rate limit) by the *account-level* wording:
435
444
  * "usage limit", "quota", "upgrade for higher limits", "exceeded your
436
- * … quota", or an auth/credential failure (401/403 with word boundaries so a
437
- * port like 4019 doesn't false-positive). The parent should resume with a
438
- * different `model` (see `resumeFrom` + `model`). */
445
+ * … quota", an auth/credential failure (401/403 with word boundaries so a
446
+ * port like 4019 doesn't false-positive), or an invalidated OAuth token
447
+ * ("invalid"/"invalidated" anywhere alongside "oauth token"). The parent
448
+ * should resume with a different `model` (see `resumeFrom` + `model`). */
439
449
  export function isModelAttributableError(error: string | undefined): boolean {
440
450
  if (!error) return false;
441
451
  const e = error.toLowerCase();
@@ -454,6 +464,7 @@ export function isModelAttributableError(error: string | undefined): boolean {
454
464
  e.includes("authentication") ||
455
465
  e.includes("invalid api key") ||
456
466
  (e.includes("api key") && e.includes("invalid")) ||
467
+ (e.includes("oauth token") && e.includes("invalid")) ||
457
468
  /\b401\b/.test(e) ||
458
469
  /\b403\b/.test(e)
459
470
  );
package/manual.ts CHANGED
@@ -138,6 +138,8 @@ export function getSubagentManualMarkdown(
138
138
  "",
139
139
  ...builtinLines,
140
140
  "",
141
+ "Prefer `default` for general work: it is the only built-in guaranteed to run the parent's exact model and thinking. `scout`/`coder`/`reviewer` apply any configured delegate.json tiers (`agentOverrides`, `agentOverridesByParentModel`) and may run a different model or thinking level than the parent. `scout` is read-only, and `reviewer` runs in a disposable scratch workspace — its writes are discarded by design. Choose a specialist for its role, not its name.",
142
+ "",
141
143
  "Fresh built-ins inherit the parent's exact model object and thinking level. A same-named Markdown file can override any built-in (first definition wins); an explicit `model` or `thinking` in that file replaces parent inheritance. Task-level `model`/`thinking`/`tools` always win. For `scout`/`coder`/`reviewer`, delegate.json overrides (`agentOverrides`, `agentOverridesByParentModel`) win over the Markdown file; `default` ignores overrides and uses only an explicit Markdown `model`/`thinking` when present. A prompt-only Markdown override keeps the built-in's tools and workspace, so `scout` stays read-only and `reviewer` stays scratch unless the file explicitly changes them. Parent extension/MCP tools are not copied. Parent-global `AGENTS.md` instructions are also excluded. Project-local context and skills are rebuilt for the task's `cwd`; per-task fields remain explicit overrides.",
142
144
  "",
143
145
  "## Available Custom Agents",
@@ -177,10 +179,10 @@ export function getSubagentManualMarkdown(
177
179
  'delegate({ tasks: [{ prompt: "Now check the tests for that module", sessionId: "auth-research" }] })',
178
180
  "",
179
181
  "// Clean up when done",
180
- 'delegate({ tasks: [{ sessionId: "auth-research", sessionAction: "close" }] })',
182
+ 'delegate({ sessionAction: "close", sessionId: "auth-research" })',
181
183
  "```",
182
184
  "",
183
- 'Pooled sessions remain live until `sessionAction: "close"` or parent Pi session shutdown.',
185
+ 'Pooled sessions remain live until a top-level `sessionAction: "close"` or parent Pi session shutdown.',
184
186
  "",
185
187
  "## Resuming Previous Sessions",
186
188
  "",
@@ -239,7 +241,7 @@ export function getSubagentManualMarkdown(
239
241
  '- Shared writers that overlap tasks in the same call or a running sync/async dispatch are rejected. Unknown tools count as mutating. Run them sequentially, use `workspace: "isolated"` for Git-backed ordered reconciliation, or use `workspace: "scratch"` when changes may be discarded.',
240
242
  "- `*` means read/write/edit/bash, not every tool. `grep`, `find`, and `ls` are valid explicit tools and are the `ro` preset.",
241
243
  '- `tasks` is an array. The tool recovers common stringified calls for compatibility, but canonical calls use `{ tasks: [{ prompt: "..." }] }`.',
242
- '- Use `agent: "default"` for the parent\'s live model/thinking/native tools/base prompt. Built-ins are `default`, `scout`, `coder`, and `reviewer`; omitting `agent` creates an ad-hoc task.',
244
+ '- Prefer `agent: "default"` the parent\'s live model/thinking/native tools/base prompt for general tasks. Pick `scout`/`coder`/`reviewer` only when the role, tool limits, or scratch workspace fit; they follow delegate.json tiers and may not run the parent model. Omitting `agent` creates an ad-hoc task (parent model, full tools).',
243
245
  "- Omit `thinking` for named agents unless the user asks or the task clearly needs escalation — task-level `thinking` overrides every configured tier (`agentOverrides`, `agentOverridesByParentModel`, Markdown frontmatter, `:level` model suffix), so a casual value silently defeats the configured budget. Ad-hoc tasks default to the parent's thinking.",
244
246
  "- An ad-hoc task with no `tools` uses `*`; a named custom task uses its profile; a profile with no tools uses `*`.",
245
247
  "- Subagents inherit all skills discovered in their `cwd` (via AgentSession's resource loader). Per-task skill filtering is not supported — curate the cwd's skill set instead.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bermudi/pi-delegate",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Delegate tool for the Pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -470,7 +470,8 @@ export function renderFinalBranch(ctx: BranchCtx, h: RenderHelpers): void {
470
470
  );
471
471
  }
472
472
 
473
- // Warnings (e.g. unknown tools ignored) — muted line under the task.
473
+ // Warnings (e.g. scratch-workspace notices, ignored model suffix) — muted
474
+ // line under the task.
474
475
  pushWarnings(p, ind);
475
476
 
476
477
  // Tool activities: compact summary only in expanded mode, terminal tasks only.