@cr1ms0n/pi-subagent 0.8.1 → 0.8.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.3 — 2026-09-10
4
+
5
+ ### Model-policy thinking defaults
6
+
7
+ - Allow `modelPolicy.default` and named-agent routes in `~/.pi/subagent.json`
8
+ to specify an optional `thinking` default (`off`, `minimal`, `low`, `medium`,
9
+ `high`, or `xhigh`). Explicit task, agent, and profile defaults still win;
10
+ unsupported backends reject resolved thinking instead of silently ignoring it.
11
+
3
12
  ## 0.8.1 — @cr1ms0n community fork (2026-09-08)
4
13
 
5
14
  - Require an explicit model matching user-owned default/agent model policy.
@@ -12,7 +21,15 @@
12
21
  The entries below are retained upstream history from @parke.dev/pi-subagent.
13
22
 
14
23
 
15
- ## Unreleased
24
+ ## 0.8.2 — 2026-09-10
25
+
26
+ ### Background completion delivery
27
+
28
+ - Automatic terminal notifications for `async: true` runs now use Pi's `steer`
29
+ delivery queue with `triggerTurn: true`, making completion messages available
30
+ before the parent's next LLM call. Foreground runs, explicit management
31
+ actions, message content, batching, persistence, and usage accounting are
32
+ unchanged.
16
33
 
17
34
  ### Machine-wide worktree GC
18
35
 
package/README.md CHANGED
@@ -238,14 +238,29 @@ fallback lists are ignored for model selection. The minimal template is:
238
238
  ```json
239
239
  {
240
240
  "modelPolicy": {
241
- "default": { "model": "<provider/model-id>", "fallbackModels": [] },
241
+ "default": {
242
+ "model": "<provider/model-id>",
243
+ "fallbackModels": [],
244
+ "thinking": "medium"
245
+ },
242
246
  "agents": {
243
- "<agent-name>": { "model": "<provider/model-id>", "fallbackModels": [] }
247
+ "<agent-name>": {
248
+ "model": "<provider/model-id>",
249
+ "fallbackModels": [],
250
+ "thinking": "high"
251
+ }
244
252
  }
245
253
  }
246
254
  }
247
255
  ```
248
256
 
257
+ `thinking` is optional and accepts `off`, `minimal`, `low`, `medium`, `high`,
258
+ or `xhigh`. It is a route default, not a strict policy value. Resolution order
259
+ is: explicit task `thinking` > agent frontmatter `thinking` > profile
260
+ `taskDefaults.<profile>.thinking` > the selected `modelPolicy` route's
261
+ `thinking` > the parent session's thinking level. Omitting it preserves the
262
+ existing behavior.
263
+
249
264
  Every new `task`/`tasks[]` item must pass the exact mapped `model`. Omit
250
265
  `fallback_models` to use the configured list; when supplied it must match the
251
266
  configured order exactly. Management actions remain available when this file is
@@ -319,7 +334,8 @@ and file changes are picked up within seconds — no restart needed.
319
334
  `taskDefaults` in `~/.pi/subagent.json` remains available for non-model
320
335
  fields such as thinking, budgets, and retry counts. Its legacy `model` and
321
336
  `fallbackModels` fields are ignored; model routing belongs only to
322
- `modelPolicy`. Invalid fields are dropped field-by-field.
337
+ `modelPolicy`. A profile `thinking` value overrides the selected route default.
338
+ Invalid fields are dropped field-by-field.
323
339
 
324
340
  Notes on behavior:
325
341
 
@@ -5,9 +5,9 @@
5
5
  > the plan: the inline spinner uses a wall-clock frame (Pi's working indicator
6
6
  > drives repaints, so no timer is owned); the ledger command landed as
7
7
  > `/subagent-cost` via `ctx.ui.notify`. Phase 4 shipped as the background-only
8
- > ambient widget plus batched `followUp` completion notifications with
9
- > flush-time wait-suppression (see docs/UX.md). The `description` param is the
10
- > task label across all surfaces. This document is retained as design rationale.
8
+ > ambient widget plus batched `steer` completion notifications with flush-time
9
+ > wait-suppression (see docs/UX.md). The `description` param is the task label
10
+ > across all surfaces. This document is retained as design rationale.
11
11
 
12
12
  Goal: remove the duplicated cost footer, make inline chat rendering clean and
13
13
  smooth, and align every surface with Pi's native conventions. Informed by a
@@ -137,7 +137,7 @@ Files: `src/extension.ts`, new `src/notifications.ts`.
137
137
  ```
138
138
 
139
139
  Cleared when no background runs. Config: `widget: "background" | "off"`.
140
- - **Completion notifications** via `pi.sendMessage(…, { deliverAs: "followUp",
140
+ - **Completion notifications** via `pi.sendMessage(…, { deliverAs: "steer",
141
141
  triggerTurn: true })` + `registerMessageRenderer`: themed compact box for the
142
142
  human (state, stats, first-line preview, artifact/session pointers),
143
143
  structured payload for the model. Replaces the bare `ctx.ui.notify` toast.
package/docs/UX.md CHANGED
@@ -66,11 +66,11 @@ Cleared when the last background run settles. Spinner and elapsed animate on
66
66
  a 250ms interval that exists only while background runs are live.
67
67
 
68
68
  ### Completion notifications (background runs only)
69
- When an async run reaches a terminal state, a `followUp` message (custom type
70
- `subagent-completion`) notifies the parent LLM so it reacts without polling.
71
- The human sees a themed compact box (state glyph, label, stats, one-line
72
- preview, artifact pointers); the LLM sees plain text with run ids and a
73
- `wait { id }` pointer.
69
+ When an async run reaches a terminal state, a `steer` message (custom type
70
+ `subagent-completion`) is queued for the parent LLM before its next LLM call,
71
+ so it can react without polling. The human sees a themed compact box (state
72
+ glyph, label, stats, one-line preview, artifact pointers); the LLM sees plain
73
+ text with run ids and a `wait { id }` pointer.
74
74
 
75
75
  - Successes within a short window batch into one message (no fanout spam);
76
76
  failures bypass batching and flush immediately, carrying held successes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cr1ms0n/pi-subagent",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Community fork of Luke Parke's pi-subagent with explicit model policy and model visibility for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -97,7 +97,9 @@ Every new task must pass a `model` that exactly matches the current
97
97
  `taskDefaults.model`, and parent-session model inheritance are ignored. An
98
98
  agent route replaces the default route, and configured fallback order is
99
99
  immutable. Omit `fallback_models` to use the route; if supplied, it must match
100
- exactly. Management actions do not require model. The extension re-reads this
101
- policy on each dispatch and injects it into the parent prompt. If the policy is
102
- missing or invalid, management remains available but new spawns and synthesis
103
- are rejected.
100
+ exactly. An optional route `thinking` value (`off`, `minimal`, `low`, `medium`,
101
+ `high`, or `xhigh`) is a default; explicit task, agent, and profile
102
+ `taskDefaults.thinking` values override it. Management actions do not require
103
+ model. The extension re-reads this policy on each dispatch and injects it into
104
+ the parent prompt. If the policy is missing or invalid, management remains
105
+ available but new spawns and synthesis are rejected.
package/src/agents.ts CHANGED
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import type { TaskProfile } from "./types.js";
5
- import type { ThinkingLevel } from "./config.js";
5
+ import { isThinkingLevel, type ThinkingLevel } from "./thinking.js";
6
6
  import { isPlausibleSchema } from "./structured.js";
7
7
 
8
8
  /**
@@ -23,7 +23,6 @@ import { isPlausibleSchema } from "./structured.js";
23
23
  * model policy is the only source used for new spawns.
24
24
  */
25
25
 
26
- const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
27
26
  const PROFILES = ["explore", "review", "general"] as const;
28
27
 
29
28
  export interface AgentDefinition {
@@ -178,9 +177,7 @@ export function parseAgentFile(name: string, raw: string, source: string, scope:
178
177
  }
179
178
  }
180
179
 
181
- const thinking = frontmatter.thinking && (THINKING_LEVELS as readonly string[]).includes(frontmatter.thinking)
182
- ? (frontmatter.thinking as ThinkingLevel)
183
- : undefined;
180
+ const thinking = isThinkingLevel(frontmatter.thinking) ? frontmatter.thinking : undefined;
184
181
  const profile = frontmatter.profile && (PROFILES as readonly string[]).includes(frontmatter.profile)
185
182
  ? (frontmatter.profile as TaskProfile)
186
183
  : undefined;
package/src/backend.ts CHANGED
@@ -139,6 +139,9 @@ export function checkCapabilities(
139
139
  `backend '${backend}' cannot restrict the child's tools, so profile '${spec.profile ?? "explore"}' cannot be enforced; use profile:'general' with an explicitly writable backend, or the pi backend`,
140
140
  );
141
141
  }
142
+ if (spec.thinking !== undefined && !capabilities.thinking) {
143
+ problems.push(`backend '${backend}' does not support thinking level '${spec.thinking}'; omit thinking or use the pi backend`);
144
+ }
142
145
  if (spec.outputSchema && !capabilities.outputSchema) {
143
146
  problems.push(`backend '${backend}' does not support output_schema; drop it`);
144
147
  }
package/src/config.ts CHANGED
@@ -3,9 +3,9 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import type { TaskProfile } from "./types.js";
5
5
  import { MODEL_POLICY_CONFIG_FILE, parseModelPolicy, type ModelPolicySnapshot } from "./model-policy.js";
6
+ import { isThinkingLevel, type ThinkingLevel } from "./thinking.js";
6
7
 
7
- const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
8
- export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
8
+ export type { ThinkingLevel } from "./thinking.js";
9
9
 
10
10
  const WIDGET_MODES = ["background", "off"] as const;
11
11
  export type WidgetMode = (typeof WIDGET_MODES)[number];
@@ -83,7 +83,7 @@ export interface SubagentConfig {
83
83
  */
84
84
  widget: WidgetMode;
85
85
  /**
86
- * Batched completion followUp messages for async runs. `"off"` disables the
86
+ * Batched completion messages for async runs. `"off"` disables the
87
87
  * CompletionBatcher so the parent is not notified on finish.
88
88
  */
89
89
  notifications: NotificationMode;
@@ -139,9 +139,7 @@ function prune<T extends object>(value: Partial<T>): Partial<T> {
139
139
  function sanitizeTaskDefaults(raw: unknown): TaskDefaults | undefined {
140
140
  if (!raw || typeof raw !== "object") return undefined;
141
141
  const value = raw as Record<string, unknown>;
142
- const thinking = typeof value.thinking === "string" && (THINKING_LEVELS as readonly string[]).includes(value.thinking)
143
- ? (value.thinking as ThinkingLevel)
144
- : undefined;
142
+ const thinking = isThinkingLevel(value.thinking) ? value.thinking : undefined;
145
143
  const fallbackModels = Array.isArray(value.fallbackModels)
146
144
  ? value.fallbackModels.map(nonEmptyString).filter((model): model is string => !!model)
147
145
  : undefined;
package/src/extension.ts CHANGED
@@ -566,7 +566,7 @@ async function runSynthesis(
566
566
  tools: ["read"],
567
567
  model: approved.route.model,
568
568
  fallbackModels: [...approved.route.fallbackModels],
569
- thinking: "low",
569
+ thinking: approved.route.thinking ?? "low",
570
570
  timeoutMs: Math.min(runtime.config.defaultTimeoutMs, 5 * 60_000),
571
571
  maxTurns: 8,
572
572
  },
@@ -763,7 +763,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
763
763
  runtime.unsubscribeLedger = runtime.registry.subscribe((event) => {
764
764
  if (event.sessionKey === runtime.key) runtime.ledgerDirty = true;
765
765
  });
766
- // Background-run completion notifications: batched followUp messages so
766
+ // Background-run completion notifications: batched steer messages so
767
767
  // the parent LLM reacts without polling. Foreground runs deliver inline.
768
768
  // `notifications: "off"` skips construction; subscribe still uses optional chain.
769
769
  if (runtime.config.notifications !== "off") {
@@ -806,7 +806,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
806
806
  ].join("\n"),
807
807
  display: true,
808
808
  details,
809
- }, { deliverAs: "followUp", triggerTurn: true });
809
+ }, { deliverAs: "steer", triggerTurn: true });
810
810
  });
811
811
  }
812
812
 
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { THINKING_LEVELS, isThinkingLevel, type ThinkingLevel } from "./thinking.js";
4
5
 
5
6
  export const MODEL_POLICY_CONFIG_FILE = path.join(os.homedir(), ".pi", "subagent.json");
6
7
 
@@ -8,6 +9,8 @@ export interface ModelRoute {
8
9
  model: string;
9
10
  /** Immutable order owned by the user configuration. */
10
11
  fallbackModels: readonly string[];
12
+ /** Optional route default; callers may override it with task/agent/profile settings. */
13
+ thinking?: ThinkingLevel;
11
14
  }
12
15
 
13
16
  export interface ModelPolicySnapshot {
@@ -42,7 +45,7 @@ function route(value: unknown, pathName: string, source: string): ModelRoute {
42
45
  invalid(source, `${pathName} must be an object`);
43
46
  }
44
47
  const record = value as Record<string, unknown>;
45
- const unknown = Object.keys(record).filter((key) => key !== "model" && key !== "fallbackModels");
48
+ const unknown = Object.keys(record).filter((key) => key !== "model" && key !== "fallbackModels" && key !== "thinking");
46
49
  if (unknown.length) invalid(source, `${pathName} has unknown field(s): ${unknown.join(", ")}`);
47
50
  const model = modelId(record.model, pathName, source);
48
51
  const rawFallbacks = record.fallbackModels === undefined ? [] : record.fallbackModels;
@@ -50,7 +53,15 @@ function route(value: unknown, pathName: string, source: string): ModelRoute {
50
53
  const fallbackModels = rawFallbacks.map((value, index) => modelId(value, `${pathName}.fallbackModels[${index}]`, source));
51
54
  if (new Set(fallbackModels).size !== fallbackModels.length) invalid(source, `${pathName}.fallbackModels must not contain duplicates`);
52
55
  if (fallbackModels.includes(model)) invalid(source, `${pathName}.fallbackModels must not repeat the primary model`);
53
- return Object.freeze({ model, fallbackModels: Object.freeze([...fallbackModels]) });
56
+ const thinking = record.thinking;
57
+ if (thinking !== undefined && !isThinkingLevel(thinking)) {
58
+ invalid(source, `${pathName}.thinking must be one of: ${THINKING_LEVELS.join(", ")}`);
59
+ }
60
+ return Object.freeze({
61
+ model,
62
+ fallbackModels: Object.freeze([...fallbackModels]),
63
+ ...(thinking === undefined ? {} : { thinking }),
64
+ });
54
65
  }
55
66
 
56
67
  /** Parse only the modelPolicy subtree; no provider catalog or credentials are read. */
@@ -131,14 +142,15 @@ export function formatModelPolicyPrompt(policy: ModelPolicySnapshot | undefined,
131
142
  const lines = [
132
143
  "## Subagent model policy",
133
144
  "Every new task/tasks[] spawn must pass model explicitly and it must exactly match this mapping. Management actions do not need model.",
134
- `default (agentless and unmapped agents): model=${policy.default.model}; fallback_models=[${policy.default.fallbackModels.join(", ")}]`,
145
+ `default (agentless and unmapped agents): model=${policy.default.model}; fallback_models=[${policy.default.fallbackModels.join(", ")}]; thinking=${policy.default.thinking ?? "(unset)"}`,
135
146
  ];
136
147
  for (const [agent, route] of [...policy.agents.entries()].sort(([a], [b]) => a.localeCompare(b))) {
137
- lines.push(`agent:${agent}: model=${route.model}; fallback_models=[${route.fallbackModels.join(", ")}]`);
148
+ lines.push(`agent:${agent}: model=${route.model}; fallback_models=[${route.fallbackModels.join(", ")}]; thinking=${route.thinking ?? "(unset)"}`);
138
149
  }
139
150
  lines.push(
140
151
  "If fallback_models is omitted, the configured list is used. If it is supplied, it must match the configured list exactly, including order.",
141
- "Do not use model/fallback_models from agent frontmatter, taskDefaults, or the parent session; those legacy fields are ignored.",
152
+ "thinking is a route default: explicit task thinking, agent thinking, and profile taskDefaults.thinking override it; parent thinking is used only when the route is unset.",
153
+ "Do not use model/fallback_models from agent frontmatter, taskDefaults, or the parent session; those legacy model fields are ignored.",
142
154
  );
143
155
  return lines.join("\n");
144
156
  }
@@ -146,8 +158,8 @@ export function formatModelPolicyPrompt(policy: ModelPolicySnapshot | undefined,
146
158
  export function modelPolicyTemplate(): string {
147
159
  return JSON.stringify({
148
160
  modelPolicy: {
149
- default: { model: "<provider/model-id>", fallbackModels: [] },
150
- agents: { "<agent-name>": { model: "<provider/model-id>", fallbackModels: [] } },
161
+ default: { model: "<provider/model-id>", fallbackModels: [], thinking: "medium" },
162
+ agents: { "<agent-name>": { model: "<provider/model-id>", fallbackModels: [], thinking: "high" } },
151
163
  },
152
164
  }, null, 2);
153
165
  }
@@ -2,8 +2,8 @@
2
2
  * Background-run completion notifications.
3
3
  *
4
4
  * When an async run reaches a terminal state, the parent LLM is notified with
5
- * a followUp message (delivered when the agent is idle / between turns) so it
6
- * can react without polling status/wait. Matches the notification-as-delivery
5
+ * a steer message (queued for delivery before the next LLM call) so it can
6
+ * react without polling status/wait. Matches the notification-as-delivery
7
7
  * semantics of `wait`: whichever path delivers first wins via markDelivered.
8
8
  *
9
9
  * Batching: successes completing within a short window group into a single
package/src/policy.ts CHANGED
@@ -199,6 +199,7 @@ function normalizeTask(
199
199
  // file > per-profile config defaults > parent inheritance. Model routing was
200
200
  // validated above and is exclusively owned by modelPolicy.
201
201
  const profileDefaults: TaskDefaults = defaults.taskDefaults?.[profile] ?? {};
202
+ const effectiveThinking = item.thinking ?? agent?.thinking ?? profileDefaults.thinking ?? modelRoute.thinking ?? parent.thinking;
202
203
  const label = item.description?.trim()
203
204
  ? item.description.trim().slice(0, 60)
204
205
  : agent
@@ -223,7 +224,7 @@ function normalizeTask(
223
224
  // restricts: profile 'general' inherits the parent set and does not
224
225
  // promise a read-only sandbox.
225
226
  tools: profile === "general" ? undefined : resolved.tools,
226
- thinking: item.thinking ?? agent?.thinking,
227
+ thinking: effectiveThinking,
227
228
  outputSchema: item.output_schema ?? agent?.outputSchema,
228
229
  profile,
229
230
  canWrite: resolved.canWrite,
@@ -244,7 +245,7 @@ function normalizeTask(
244
245
  // Model routing is exclusively owned by modelPolicy. Legacy agent,
245
246
  // taskDefaults, and parent-session model fields never participate.
246
247
  model: item.model!.trim(),
247
- thinking: item.thinking ?? agent?.thinking ?? profileDefaults.thinking ?? parent.thinking,
248
+ thinking: effectiveThinking,
248
249
  tools: resolved.tools,
249
250
  profile,
250
251
  cwd,
package/src/schema.ts CHANGED
@@ -31,7 +31,7 @@ export const TaskFields = {
31
31
  description: Type.Optional(Type.String({ description: "Short human label (3-5 words) shown in UIs and result indexes." })),
32
32
  system_prompt: Type.Optional(Type.String({ description: "Extra system prompt appended to the child's prompt (does not replace it)." })),
33
33
  model: Type.Optional(Type.String({ description: "**REQUIRED for every spawn call (task/tasks).** Exact model id from modelPolicy, in provider/model-id form. Calls without an explicit model are rejected; agent-file model, taskDefaults, and parent-session inheritance are ignored. Management actions (status/wait/cancel/steer/diff/apply/discard) do not need it." })),
34
- thinking: Type.Optional({ ...ThinkingLevel, description: "Reasoning effort for the child. Defaults to profile config default, then the parent's level." }),
34
+ thinking: Type.Optional({ ...ThinkingLevel, description: "Reasoning effort for the child. Defaults to agent thinking, profile taskDefaults.thinking, modelPolicy route thinking, then the parent's level." }),
35
35
  tools: Type.Optional(Type.Array(Type.String(), { description: "Optional tool allowlist. explore/review profiles reject write-capable tools." })),
36
36
  profile: Type.Optional({ ...Profile, description: "Capability profile: explore/review are strictly read-only; general inherits the parent's active tools and may write." }),
37
37
  cwd: Type.Optional(Type.String({ description: "Working directory for the child process." })),
@@ -0,0 +1,6 @@
1
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
2
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
3
+
4
+ export function isThinkingLevel(value: unknown): value is ThinkingLevel {
5
+ return typeof value === "string" && (THINKING_LEVELS as readonly string[]).includes(value);
6
+ }
package/src/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Message } from "@earendil-works/pi-ai";
2
+ import type { ThinkingLevel } from "./thinking.js";
2
3
 
3
4
  export type RunMode = "single" | "parallel";
4
5
  export type RunState = "queued" | "running" | "completed" | "partial" | "failed" | "cancelled" | "lost" | "timeout";
@@ -44,7 +45,7 @@ export interface TaskSpec {
44
45
  label?: string;
45
46
  systemPrompt?: string;
46
47
  model?: string;
47
- thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
48
+ thinking?: ThinkingLevel;
48
49
  tools?: string[];
49
50
  profile: TaskProfile;
50
51
  canWrite?: boolean;