pi-subagents 0.32.0 → 0.33.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +30 -3
  2. package/README.md +147 -58
  3. package/install.mjs +2 -1
  4. package/package.json +3 -2
  5. package/skills/pi-subagents/SKILL.md +70 -14
  6. package/src/agents/agent-management.ts +177 -5
  7. package/src/agents/agent-memory.ts +254 -0
  8. package/src/agents/agent-serializer.ts +11 -0
  9. package/src/agents/agents.ts +142 -12
  10. package/src/agents/chain-serializer.ts +27 -2
  11. package/src/extension/doctor.ts +1 -9
  12. package/src/extension/fanout-child.ts +2 -2
  13. package/src/extension/index.ts +65 -90
  14. package/src/extension/rpc.ts +369 -0
  15. package/src/extension/schemas.ts +52 -8
  16. package/src/extension/tool-description.ts +200 -0
  17. package/src/intercom/intercom-bridge.ts +21 -253
  18. package/src/intercom/native-supervisor-channel.ts +510 -0
  19. package/src/runs/background/async-execution.ts +51 -7
  20. package/src/runs/background/async-job-tracker.ts +12 -2
  21. package/src/runs/background/async-status.ts +27 -2
  22. package/src/runs/background/completion-batcher.ts +166 -0
  23. package/src/runs/background/control-channel.ts +106 -1
  24. package/src/runs/background/fleet-view.ts +515 -0
  25. package/src/runs/background/notify.ts +161 -44
  26. package/src/runs/background/result-watcher.ts +1 -2
  27. package/src/runs/background/run-id-resolver.ts +3 -2
  28. package/src/runs/background/run-status.ts +166 -6
  29. package/src/runs/background/scheduled-runs.ts +514 -0
  30. package/src/runs/background/subagent-runner.ts +409 -35
  31. package/src/runs/background/wait.ts +353 -0
  32. package/src/runs/foreground/chain-execution.ts +95 -21
  33. package/src/runs/foreground/execution.ts +150 -21
  34. package/src/runs/foreground/subagent-executor.ts +378 -64
  35. package/src/runs/shared/dynamic-fanout.ts +1 -1
  36. package/src/runs/shared/model-fallback.ts +167 -20
  37. package/src/runs/shared/model-scope.ts +128 -0
  38. package/src/runs/shared/nested-events.ts +31 -0
  39. package/src/runs/shared/parallel-utils.ts +1 -0
  40. package/src/runs/shared/pi-args.ts +30 -1
  41. package/src/runs/shared/subagent-prompt-runtime.ts +108 -2
  42. package/src/runs/shared/tool-budget.ts +74 -0
  43. package/src/runs/shared/turn-budget.ts +52 -0
  44. package/src/shared/artifacts.ts +1 -0
  45. package/src/shared/atomic-json.ts +15 -2
  46. package/src/shared/child-transcript.ts +212 -0
  47. package/src/shared/settings.ts +3 -1
  48. package/src/shared/types.ts +134 -19
  49. package/src/slash/prompt-workflows.ts +330 -0
  50. package/src/slash/slash-commands.ts +16 -2
  51. package/src/tui/render.ts +16 -8
  52. package/src/extension/companion-suggestions.ts +0 -359
@@ -45,7 +45,7 @@ const DYNAMIC_STEP_KEYS = new Set(["expand", "parallel", "collect", "concurrency
45
45
  const RUNNER_DYNAMIC_STEP_KEYS = new Set([...DYNAMIC_STEP_KEYS, "effectiveAcceptance", "sessionFiles", "thinkingOverrides"]);
46
46
  const DYNAMIC_EXPAND_KEYS = new Set(["from", "item", "key", "maxItems", "onEmpty"]);
47
47
  const DYNAMIC_EXPAND_FROM_KEYS = new Set(["output", "path"]);
48
- const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outputSchema", "cwd", "output", "outputMode", "reads", "progress", "skill", "model", "acceptance"]);
48
+ const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outputSchema", "cwd", "output", "outputMode", "reads", "progress", "skill", "model", "toolBudget", "acceptance"]);
49
49
  const RUNNER_DYNAMIC_PARALLEL_KEYS = new Set([
50
50
  ...DYNAMIC_PARALLEL_KEYS,
51
51
  "outputName", "structured", "inheritProjectContext", "inheritSkills", "skills", "outputPath", "maxSubagentDepth",
@@ -1,5 +1,6 @@
1
1
  import type { ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts";
2
2
  import type { Usage } from "../../shared/types.ts";
3
+ import { checkModelScope, type ModelScopeConfig, type ModelScopeViolation, type ModelSource } from "./model-scope.ts";
3
4
 
4
5
  export type { AvailableModelInfo };
5
6
 
@@ -29,6 +30,144 @@ export interface ParentModel {
29
30
  id: string;
30
31
  }
31
32
 
33
+ /**
34
+ * Normalize a model id or provider segment for fuzzy comparison: case-fold,
35
+ * treat dots/underscores as dashes (so `4.5` matches `4-5`), and collapse
36
+ * repeated separators. Pure.
37
+ */
38
+ export function normalizeModelSegment(segment: string): string {
39
+ return segment
40
+ .toLowerCase()
41
+ .replace(/[._]+/g, "-")
42
+ .replace(/-+/g, "-")
43
+ .replace(/^-|-$/g, "");
44
+ }
45
+
46
+ function isPlausibleDateStamp(year: string, month: string, day: string): boolean {
47
+ const yyyy = Number(year);
48
+ const mm = Number(month);
49
+ const dd = Number(day);
50
+ return yyyy >= 1900 && yyyy <= 2099 && mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31;
51
+ }
52
+
53
+ /** Drop a trailing date stamp (`-20251001` or `-2025-10-01`) so dated and undated ids match. Pure. */
54
+ function stripTrailingDateStamp(segment: string): string {
55
+ const dashed = /^(.*)-(\d{4})-(\d{2})-(\d{2})$/.exec(segment);
56
+ if (dashed && isPlausibleDateStamp(dashed[2]!, dashed[3]!, dashed[4]!)) return dashed[1]!;
57
+ const compact = /^(.*)-(\d{4})(\d{2})(\d{2})$/.exec(segment);
58
+ if (compact && isPlausibleDateStamp(compact[2]!, compact[3]!, compact[4]!)) return compact[1]!;
59
+ return segment;
60
+ }
61
+
62
+ function resolveBaseModelCandidate(
63
+ baseModel: string,
64
+ availableModels: AvailableModelInfo[],
65
+ preferredProvider?: string,
66
+ ): string | undefined {
67
+ if (baseModel.includes("/")) {
68
+ const exact = availableModels.find((entry) => entry.fullId === baseModel);
69
+ if (exact) return exact.fullId;
70
+ } else {
71
+ const exactMatches = availableModels.filter((entry) => entry.id === baseModel);
72
+ if (preferredProvider) {
73
+ const preferredMatch = exactMatches.find((entry) => entry.provider === preferredProvider);
74
+ if (preferredMatch) return preferredMatch.fullId;
75
+ }
76
+ if (exactMatches.length === 1) return exactMatches[0]!.fullId;
77
+ }
78
+
79
+ return fuzzyResolveModel(baseModel, availableModels, preferredProvider);
80
+ }
81
+
82
+ /**
83
+ * Fuzzy-resolve a base model id (thinking suffix already stripped) against the
84
+ * registry, tolerating separator, case, and optional date-stamp differences so
85
+ * users do not have to spell provider/model exactly. A qualified `provider/id`
86
+ * query only matches within the named provider — this never silently switches
87
+ * providers for security/cost-sensitive configs. Returns the matched `fullId`,
88
+ * or `undefined` when there is no match or the match is ambiguous across
89
+ * providers (and no `preferredProvider` disambiguates). Pure.
90
+ */
91
+ export function fuzzyResolveModel(
92
+ baseModel: string,
93
+ availableModels: AvailableModelInfo[],
94
+ preferredProvider?: string,
95
+ ): string | undefined {
96
+ let queryProvider: string | undefined;
97
+ let queryIdRaw = baseModel;
98
+ const slashIdx = baseModel.indexOf("/");
99
+ if (slashIdx !== -1) {
100
+ queryProvider = normalizeModelSegment(baseModel.slice(0, slashIdx));
101
+ queryIdRaw = baseModel.slice(slashIdx + 1);
102
+ } else {
103
+ const providerSeparators = [":", "."];
104
+ for (const separator of providerSeparators) {
105
+ const separatorIdx = baseModel.indexOf(separator);
106
+ if (separatorIdx <= 0) continue;
107
+ const providerPart = normalizeModelSegment(baseModel.slice(0, separatorIdx));
108
+ if (!availableModels.some((entry) => normalizeModelSegment(entry.provider) === providerPart)) continue;
109
+ queryProvider = providerPart;
110
+ queryIdRaw = baseModel.slice(separatorIdx + 1);
111
+ break;
112
+ }
113
+ }
114
+ const queryId = normalizeModelSegment(queryIdRaw);
115
+ const queryIdNoDate = stripTrailingDateStamp(queryId);
116
+
117
+ const candidates = availableModels.filter((entry) => {
118
+ const entryId = normalizeModelSegment(entry.id);
119
+ if (entryId !== queryId && stripTrailingDateStamp(entryId) !== queryIdNoDate) return false;
120
+ if (queryProvider !== undefined && normalizeModelSegment(entry.provider) !== queryProvider) return false;
121
+ return true;
122
+ });
123
+ if (candidates.length === 0) return undefined;
124
+ if (preferredProvider) {
125
+ const preferredProviderNorm = normalizeModelSegment(preferredProvider);
126
+ const preferred = candidates.find((entry) => normalizeModelSegment(entry.provider) === preferredProviderNorm);
127
+ if (preferred) return preferred.fullId;
128
+ }
129
+ if (candidates.length === 1) return candidates[0]!.fullId;
130
+ return undefined;
131
+ }
132
+
133
+ /**
134
+ * Resolve a possibly-loose model id to a canonical `provider/id` (plus any
135
+ * thinking suffix). Exact registry matches win; fuzzy normalization
136
+ * (separator/case/date-stamp via {@link fuzzyResolveModel}) is a fallback so
137
+ * spelling differences still resolve. Never switches providers for a qualified
138
+ * query. Pure.
139
+ */
140
+ export function resolveModelCandidate(
141
+ model: string | undefined,
142
+ availableModels: AvailableModelInfo[] | undefined,
143
+ preferredProvider?: string,
144
+ ): string | undefined {
145
+ if (!model) return undefined;
146
+ if (!availableModels || availableModels.length === 0) return model;
147
+
148
+ const resolvedWhole = resolveBaseModelCandidate(model, availableModels, preferredProvider);
149
+ if (resolvedWhole) return resolvedWhole;
150
+
151
+ const { baseModel, thinkingSuffix } = splitThinkingSuffix(model);
152
+ if (!thinkingSuffix) return model;
153
+ const resolvedBase = resolveBaseModelCandidate(baseModel, availableModels, preferredProvider);
154
+ if (resolvedBase) return `${resolvedBase}${thinkingSuffix}`;
155
+ return model;
156
+ }
157
+
158
+ export interface ResolveSubagentModelOverrideOptions {
159
+ /** When set with `enforce: true`, out-of-scope models are rejected. */
160
+ scope?: ModelScopeConfig;
161
+ /** Origin of the requested model: explicit caller-supplied (hard error) vs inherited (warn). Defaults to `"inherited"`. */
162
+ source?: ModelSource;
163
+ /** Called for warn-severity violations instead of `console.warn`. */
164
+ onWarn?: (violation: ModelScopeViolation) => void;
165
+ }
166
+
167
+ function defaultScopeWarn(violation: ModelScopeViolation): void {
168
+ console.warn(`[pi-subagents] ${violation.message}`);
169
+ }
170
+
32
171
  /**
33
172
  * Resolve the `--model` override passed to a spawned subagent.
34
173
  *
@@ -43,38 +182,39 @@ export interface ParentModel {
43
182
  * session's model.
44
183
  *
45
184
  * An explicitly requested model string is resolved via {@link resolveModelCandidate}.
185
+ * When `options.scope.enforce` is on, an out-of-scope resolved model throws for
186
+ * an explicit (`source: "explicit"`) request and warns for an inherited one.
46
187
  */
47
188
  export function resolveSubagentModelOverride(
48
189
  requestedModel: string | boolean | undefined,
49
190
  parentModel: ParentModel | undefined,
50
191
  availableModels: AvailableModelInfo[] | undefined,
51
192
  preferredProvider?: string,
193
+ options?: ResolveSubagentModelOverrideOptions,
52
194
  ): string | undefined {
53
195
  const trimmed = typeof requestedModel === "string" ? requestedModel.trim() : "";
54
196
  const explicit = trimmed && trimmed !== INHERIT_MODEL ? trimmed : undefined;
197
+ let resolved: string | undefined;
55
198
  if (explicit === undefined) {
56
- return parentModel ? `${parentModel.provider}/${parentModel.id}` : undefined;
199
+ resolved = parentModel ? `${parentModel.provider}/${parentModel.id}` : undefined;
200
+ } else {
201
+ resolved = resolveModelCandidate(explicit, availableModels, preferredProvider);
202
+ }
203
+ if (resolved && options?.scope?.enforce) {
204
+ const source: ModelSource = explicit === undefined ? "inherited" : (options.source ?? "inherited");
205
+ const violation = checkModelScope(resolved, options.scope, source);
206
+ if (violation) {
207
+ if (violation.severity === "error") throw new Error(violation.message);
208
+ (options.onWarn ?? defaultScopeWarn)(violation);
209
+ }
57
210
  }
58
- return resolveModelCandidate(explicit, availableModels, preferredProvider);
211
+ return resolved;
59
212
  }
60
213
 
61
- export function resolveModelCandidate(
62
- model: string | undefined,
63
- availableModels: AvailableModelInfo[] | undefined,
64
- preferredProvider?: string,
65
- ): string | undefined {
66
- if (!model) return undefined;
67
- if (model.includes("/")) return model;
68
- if (!availableModels || availableModels.length === 0) return model;
69
-
70
- const { baseModel, thinkingSuffix } = splitThinkingSuffix(model);
71
- const matches = availableModels.filter((entry) => entry.id === baseModel);
72
- if (preferredProvider) {
73
- const preferredMatch = matches.find((entry) => entry.provider === preferredProvider);
74
- if (preferredMatch) return `${preferredMatch.fullId}${thinkingSuffix}`;
75
- }
76
- if (matches.length !== 1) return model;
77
- return `${matches[0]!.fullId}${thinkingSuffix}`;
214
+ export interface BuildModelCandidatesOptions {
215
+ /** Fallback models are inherited agent config and warn, rather than error, when out of scope. */
216
+ scope?: ModelScopeConfig;
217
+ onWarn?: (violation: ModelScopeViolation) => void;
78
218
  }
79
219
 
80
220
  export function buildModelCandidates(
@@ -82,13 +222,20 @@ export function buildModelCandidates(
82
222
  fallbackModels: string[] | undefined,
83
223
  availableModels: AvailableModelInfo[] | undefined,
84
224
  preferredProvider?: string,
225
+ options?: BuildModelCandidatesOptions,
85
226
  ): string[] {
86
227
  const seen = new Set<string>();
87
228
  const candidates: string[] = [];
88
- for (const raw of [primaryModel, ...(fallbackModels ?? [])]) {
229
+ const rawCandidates = [primaryModel, ...(fallbackModels ?? [])];
230
+ for (let index = 0; index < rawCandidates.length; index++) {
231
+ const raw = rawCandidates[index];
89
232
  if (!raw) continue;
90
233
  const normalized = resolveModelCandidate(raw.trim(), availableModels, preferredProvider);
91
234
  if (!normalized || seen.has(normalized)) continue;
235
+ if (index > 0 && options?.scope?.enforce) {
236
+ const violation = checkModelScope(normalized, options.scope, "inherited");
237
+ if (violation) (options.onWarn ?? defaultScopeWarn)(violation);
238
+ }
92
239
  seen.add(normalized);
93
240
  candidates.push(normalized);
94
241
  }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Optional model-scope enforcement for subagent model resolution.
3
+ *
4
+ * When `subagents.modelScope.enforce` is enabled in settings, a resolved model
5
+ * that does not match any `allow` pattern is rejected. The severity depends on
6
+ * where the model came from: an explicit caller-supplied model (`--model`,
7
+ * tool-call `model`, or a TUI clarify pick) is a hard error, while a model
8
+ * inherited from agent frontmatter / `defaultModel` / the parent session only
9
+ * emits a warning so existing configurations keep working.
10
+ *
11
+ * The decision logic ({@link checkModelScope}) is a pure function of its
12
+ * inputs so it can be unit-tested without touching the filesystem or config.
13
+ */
14
+
15
+ import { splitKnownThinkingSuffix } from "../../shared/model-info.ts";
16
+
17
+ export interface ModelScopeConfig {
18
+ enforce?: boolean;
19
+ /** Glob-style allow patterns (only `*` is special), matched against `provider/id`. */
20
+ allow?: string[];
21
+ }
22
+
23
+ /** Where a resolved model originated, deciding enforcement severity. */
24
+ export type ModelSource = "explicit" | "inherited";
25
+
26
+ export interface ModelScopeViolation {
27
+ /** Resolved model id (without thinking suffix) that fell outside the scope. */
28
+ model: string;
29
+ severity: "warn" | "error";
30
+ message: string;
31
+ allowedPatterns: string[];
32
+ }
33
+
34
+ function stripThinkingSuffix(model: string): string {
35
+ return splitKnownThinkingSuffix(model).baseModel;
36
+ }
37
+
38
+ /** Escape RegExp specials except `*`, then turn `*` into `.*`. */
39
+ function globToRegExp(pattern: string): RegExp {
40
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
41
+ return new RegExp(`^${escaped}$`, "i");
42
+ }
43
+
44
+ /**
45
+ * Test whether a resolved model matches a single allow pattern. Both sides are
46
+ * compared case-insensitively against the full `provider/id` (thinking suffix
47
+ * stripped from the model).
48
+ */
49
+ export function matchesScopePattern(model: string, pattern: string): boolean {
50
+ return globToRegExp(pattern).test(stripThinkingSuffix(model));
51
+ }
52
+
53
+ /**
54
+ * Pure scope decision. Returns a {@link ModelScopeViolation} when the model is
55
+ * out of scope and enforcement is on, otherwise `undefined`. Enforcement with
56
+ * no `allow` list is a no-op (the settings parser rejects that combination, but
57
+ * this stays defensive for callers that build configs programmatically).
58
+ */
59
+ export function checkModelScope(
60
+ model: string | undefined,
61
+ scope: ModelScopeConfig | undefined,
62
+ source: ModelSource,
63
+ ): ModelScopeViolation | undefined {
64
+ if (!model || !scope?.enforce) return undefined;
65
+ const allow = scope.allow;
66
+ if (!allow || allow.length === 0) return undefined;
67
+ if (allow.some((pattern) => matchesScopePattern(model, pattern))) return undefined;
68
+
69
+ const baseModel = stripThinkingSuffix(model);
70
+ const severity: ModelScopeViolation["severity"] = source === "explicit" ? "error" : "warn";
71
+ return {
72
+ model: baseModel,
73
+ severity,
74
+ allowedPatterns: allow,
75
+ message:
76
+ `Model '${baseModel}' is outside the configured subagent model scope. ` +
77
+ `Allowed patterns: ${allow.join(", ")}.`,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Validate and normalize a raw `subagents.modelScope` value from settings.
83
+ * Throws a descriptive error for malformed configs (matching the surrounding
84
+ * settings-parsing style). Returns `undefined` when the field is absent.
85
+ */
86
+ export function parseModelScopeConfig(
87
+ value: unknown,
88
+ meta: { filePath: string },
89
+ ): ModelScopeConfig | undefined {
90
+ if (value === undefined) return undefined;
91
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
92
+ throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope'; expected an object.`);
93
+ }
94
+
95
+ const input = value as Record<string, unknown>;
96
+ const config: ModelScopeConfig = {};
97
+
98
+ if ("enforce" in input) {
99
+ if (typeof input.enforce !== "boolean") {
100
+ throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.enforce'; expected a boolean.`);
101
+ }
102
+ config.enforce = input.enforce;
103
+ }
104
+
105
+ if ("allow" in input) {
106
+ if (!Array.isArray(input.allow)) {
107
+ throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.allow'; expected an array of strings.`);
108
+ }
109
+ const allow: string[] = [];
110
+ for (const entry of input.allow) {
111
+ if (typeof entry !== "string") {
112
+ throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.allow'; expected an array of strings.`);
113
+ }
114
+ const trimmed = entry.trim();
115
+ if (trimmed) allow.push(trimmed);
116
+ }
117
+ if (allow.length === 0) {
118
+ throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.allow'; expected a non-empty array of patterns.`);
119
+ }
120
+ config.allow = allow;
121
+ }
122
+
123
+ if (config.enforce === true && (!config.allow || config.allow.length === 0)) {
124
+ throw new Error(`Subagent settings in '${meta.filePath}' set modelScope.enforce without a non-empty 'allow' list; supply allowed model patterns or disable enforcement.`);
125
+ }
126
+
127
+ return Object.keys(config).length > 0 ? config : undefined;
128
+ }
@@ -8,6 +8,7 @@ import {
8
8
  type AsyncJobState,
9
9
  type AsyncStatus,
10
10
  type NestedRouteInfo,
11
+ type TurnBudgetState,
11
12
  type NestedRunSummary,
12
13
  type NestedRunState,
13
14
  type NestedStepSummary,
@@ -195,6 +196,24 @@ function sanitizeCost(value: unknown): NestedRunSummary["totalCost"] | undefined
195
196
  : undefined;
196
197
  }
197
198
 
199
+ function sanitizeTurnBudget(value: unknown): TurnBudgetState | undefined {
200
+ if (!value || typeof value !== "object") return undefined;
201
+ const raw = value as Record<string, unknown>;
202
+ const maxTurns = clampNumber(raw.maxTurns);
203
+ const graceTurns = clampNumber(raw.graceTurns);
204
+ const turnCount = clampNumber(raw.turnCount);
205
+ const outcome = raw.outcome === "within-budget" || raw.outcome === "wrap-up-requested" || raw.outcome === "exceeded" ? raw.outcome : undefined;
206
+ if (maxTurns === undefined || graceTurns === undefined || turnCount === undefined || !outcome) return undefined;
207
+ return {
208
+ maxTurns,
209
+ graceTurns,
210
+ turnCount,
211
+ outcome,
212
+ ...(clampNumber(raw.wrapUpRequestedAtTurn) !== undefined ? { wrapUpRequestedAtTurn: clampNumber(raw.wrapUpRequestedAtTurn) } : {}),
213
+ ...(clampNumber(raw.exceededAtTurn) !== undefined ? { exceededAtTurn: clampNumber(raw.exceededAtTurn) } : {}),
214
+ };
215
+ }
216
+
198
217
  function sanitizeState(value: unknown, fallback: NestedRunState): NestedRunState {
199
218
  return value === "queued" || value === "running" || value === "complete" || value === "failed" || value === "paused"
200
219
  ? value
@@ -224,6 +243,9 @@ function sanitizeStep(input: unknown, depth: number): NestedStepSummary | undefi
224
243
  ...(clampNumber(raw.endedAt) !== undefined ? { endedAt: clampNumber(raw.endedAt) } : {}),
225
244
  ...(stringValue(raw.error, 1024) ? { error: stringValue(raw.error, 1024) } : {}),
226
245
  ...(raw.timedOut === true ? { timedOut: true } : {}),
246
+ ...(sanitizeTurnBudget(raw.turnBudget) ? { turnBudget: sanitizeTurnBudget(raw.turnBudget) } : {}),
247
+ ...(raw.turnBudgetExceeded === true ? { turnBudgetExceeded: true } : {}),
248
+ ...(raw.wrapUpRequested === true ? { wrapUpRequested: true } : {}),
227
249
  ...(depth < MAX_DEPTH && Array.isArray(raw.children) ? { children: raw.children.map((child) => sanitizeSummary(child, depth + 1)).filter((child): child is NestedRunSummary => Boolean(child)).slice(0, MAX_CHILDREN) } : {}),
228
250
  };
229
251
  }
@@ -276,6 +298,9 @@ export function sanitizeSummary(input: unknown, depth = 0): NestedRunSummary | u
276
298
  ...(clampNumber(raw.timeoutMs) !== undefined ? { timeoutMs: clampNumber(raw.timeoutMs) } : {}),
277
299
  ...(clampNumber(raw.deadlineAt) !== undefined ? { deadlineAt: clampNumber(raw.deadlineAt) } : {}),
278
300
  ...(raw.timedOut === true ? { timedOut: true } : {}),
301
+ ...(sanitizeTurnBudget(raw.turnBudget) ? { turnBudget: sanitizeTurnBudget(raw.turnBudget) } : {}),
302
+ ...(raw.turnBudgetExceeded === true ? { turnBudgetExceeded: true } : {}),
303
+ ...(raw.wrapUpRequested === true ? { wrapUpRequested: true } : {}),
279
304
  ...(stringValue(raw.error, 1024) ? { error: stringValue(raw.error, 1024) } : {}),
280
305
  ...(steps && steps.length > 0 ? { steps } : {}),
281
306
  ...(depth < MAX_DEPTH && Array.isArray(raw.children) ? { children: raw.children.map((child) => sanitizeSummary(child, depth + 1)).filter((child): child is NestedRunSummary => Boolean(child)).slice(0, MAX_CHILDREN) } : {}),
@@ -834,6 +859,9 @@ export function nestedSummaryFromAsyncStatus(status: AsyncStatus, asyncDir: stri
834
859
  ...(status.timeoutMs !== undefined ? { timeoutMs: status.timeoutMs } : {}),
835
860
  ...(status.deadlineAt !== undefined ? { deadlineAt: status.deadlineAt } : {}),
836
861
  ...(status.timedOut !== undefined ? { timedOut: status.timedOut } : {}),
862
+ ...(status.turnBudget ? { turnBudget: status.turnBudget } : {}),
863
+ ...(status.turnBudgetExceeded !== undefined ? { turnBudgetExceeded: status.turnBudgetExceeded } : {}),
864
+ ...(status.wrapUpRequested !== undefined ? { wrapUpRequested: status.wrapUpRequested } : {}),
837
865
  ...(status.error ? { error: status.error } : {}),
838
866
  ...(status.startedAt !== undefined ? { startedAt: status.startedAt } : { startedAt: fallback.ts }),
839
867
  ...(status.endedAt !== undefined ? { endedAt: status.endedAt } : {}),
@@ -854,6 +882,9 @@ export function nestedSummaryFromAsyncStatus(status: AsyncStatus, asyncDir: stri
854
882
  ...(step.endedAt !== undefined ? { endedAt: step.endedAt } : {}),
855
883
  ...(step.error ? { error: step.error } : {}),
856
884
  ...(step.timedOut !== undefined ? { timedOut: step.timedOut } : {}),
885
+ ...(step.turnBudget ? { turnBudget: step.turnBudget } : {}),
886
+ ...(step.turnBudgetExceeded !== undefined ? { turnBudgetExceeded: step.turnBudgetExceeded } : {}),
887
+ ...(step.wrapUpRequested !== undefined ? { wrapUpRequested: step.wrapUpRequested } : {}),
857
888
  })).slice(0, MAX_STEPS) } : {}),
858
889
  };
859
890
  }
@@ -38,6 +38,7 @@ export interface RunnerSubagentStep {
38
38
  };
39
39
  structuredOutputSchema?: import("../../shared/types.ts").JsonSchemaObject;
40
40
  effectiveAcceptance?: import("../../shared/types.ts").ResolvedAcceptanceConfig;
41
+ toolBudget?: import("../../shared/types.ts").ResolvedToolBudget;
41
42
  }
42
43
 
43
44
  export interface ParallelStepGroup {
@@ -5,7 +5,8 @@ import { fileURLToPath } from "node:url";
5
5
  import { encodeNestedPathEnv, parseNestedPathEnv, type NestedPathEntry } from "./nested-path.ts";
6
6
  import { resolveMcpDirectToolNames } from "./mcp-direct-tool-allowlist.ts";
7
7
  import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV } from "./structured-output.ts";
8
- import type { JsonSchemaObject } from "../../shared/types.ts";
8
+ import { TEMP_ROOT_DIR, type JsonSchemaObject, type ResolvedToolBudget } from "../../shared/types.ts";
9
+ import { TOOL_BUDGET_ENV, encodeToolBudgetEnv } from "./tool-budget.ts";
9
10
 
10
11
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
11
12
  const TASK_ARG_LIMIT = 8000;
@@ -13,6 +14,8 @@ const PROMPT_RUNTIME_EXTENSION_PATH = path.join(path.dirname(fileURLToPath(impor
13
14
  const FANOUT_CHILD_EXTENSION_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "extension", "fanout-child.ts");
14
15
  export const SUBAGENT_CHILD_ENV = "PI_SUBAGENT_CHILD";
15
16
  export const SUBAGENT_ORCHESTRATOR_TARGET_ENV = "PI_SUBAGENT_ORCHESTRATOR_TARGET";
17
+ export const SUBAGENT_ORCHESTRATOR_SESSION_ID_ENV = "PI_SUBAGENT_ORCHESTRATOR_SESSION_ID";
18
+ export const SUBAGENT_SUPERVISOR_CHANNEL_DIR_ENV = "PI_SUBAGENT_SUPERVISOR_CHANNEL_DIR";
16
19
  export const SUBAGENT_RUN_ID_ENV = "PI_SUBAGENT_RUN_ID";
17
20
  export const SUBAGENT_CHILD_AGENT_ENV = "PI_SUBAGENT_CHILD_AGENT";
18
21
  export const SUBAGENT_CHILD_INDEX_ENV = "PI_SUBAGENT_CHILD_INDEX";
@@ -26,6 +29,7 @@ export const SUBAGENT_PARENT_DEPTH_ENV = "PI_SUBAGENT_PARENT_DEPTH";
26
29
  export const SUBAGENT_PARENT_PATH_ENV = "PI_SUBAGENT_PARENT_PATH";
27
30
  export const SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV = "PI_SUBAGENT_PARENT_CAPABILITY_TOKEN";
28
31
  export const SUBAGENT_PARENT_SESSION_ENV = "PI_SUBAGENT_PARENT_SESSION";
32
+ export const SUBAGENT_STEER_INBOX_ENV = "PI_SUBAGENT_STEER_INBOX";
29
33
 
30
34
  interface BuildPiArgsInput {
31
35
  parentSessionId?: string;
@@ -60,11 +64,13 @@ interface BuildPiArgsInput {
60
64
  parentDepth?: number;
61
65
  parentPath?: NestedPathEntry[];
62
66
  parentCapabilityToken?: string;
67
+ steerInboxDir?: string;
63
68
  structuredOutput?: {
64
69
  schema: JsonSchemaObject;
65
70
  schemaPath: string;
66
71
  outputPath: string;
67
72
  };
73
+ toolBudget?: ResolvedToolBudget;
68
74
  }
69
75
 
70
76
  interface BuildPiArgsResult {
@@ -73,6 +79,14 @@ interface BuildPiArgsResult {
73
79
  tempDir?: string;
74
80
  }
75
81
 
82
+ function sanitizeSupervisorChannelSegment(value: string): string {
83
+ return value.trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
84
+ }
85
+
86
+ function supervisorChannelDir(runId: string, agent: string, childIndex: number): string {
87
+ return path.join(TEMP_ROOT_DIR, "supervisor-channels", `${sanitizeSupervisorChannelSegment(runId)}-${sanitizeSupervisorChannelSegment(agent)}-${childIndex}`);
88
+ }
89
+
76
90
  export function applyThinkingSuffix(model: string | undefined, thinking: string | undefined, replaceExisting = false): string | undefined {
77
91
  if (!model || !thinking) return model;
78
92
  const colonIdx = model.lastIndexOf(":");
@@ -206,6 +220,16 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
206
220
  if (input.orchestratorIntercomTarget) {
207
221
  env[SUBAGENT_ORCHESTRATOR_TARGET_ENV] = input.orchestratorIntercomTarget;
208
222
  }
223
+ if (input.parentSessionId) {
224
+ env[SUBAGENT_ORCHESTRATOR_SESSION_ID_ENV] = input.parentSessionId;
225
+ }
226
+ if (input.orchestratorIntercomTarget && input.parentSessionId && input.runId && input.childAgentName) {
227
+ const childIndex = input.childIndex ?? 0;
228
+ const channelDir = supervisorChannelDir(input.runId, input.childAgentName, childIndex);
229
+ fs.mkdirSync(path.join(channelDir, "requests"), { recursive: true });
230
+ fs.mkdirSync(path.join(channelDir, "replies"), { recursive: true });
231
+ env[SUBAGENT_SUPERVISOR_CHANNEL_DIR_ENV] = channelDir;
232
+ }
209
233
  if (input.runId) {
210
234
  env[SUBAGENT_RUN_ID_ENV] = input.runId;
211
235
  }
@@ -224,6 +248,11 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
224
248
  env[STRUCTURED_OUTPUT_CAPTURE_ENV] = input.structuredOutput.outputPath;
225
249
  env[STRUCTURED_OUTPUT_SCHEMA_ENV] = input.structuredOutput.schemaPath;
226
250
  }
251
+ if (input.steerInboxDir) {
252
+ env[SUBAGENT_STEER_INBOX_ENV] = input.steerInboxDir;
253
+ }
254
+ const encodedToolBudget = encodeToolBudgetEnv(input.toolBudget);
255
+ if (encodedToolBudget) env[TOOL_BUDGET_ENV] = encodedToolBudget;
227
256
 
228
257
  env[SUBAGENT_PARENT_SESSION_ENV] = input.parentSessionId ?? process.env[SUBAGENT_PARENT_SESSION_ENV] ?? "";
229
258
 
@@ -1,9 +1,12 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
- import { SUBAGENT_FANOUT_CHILD_ENV } from "./pi-args.ts";
4
+ import { registerNativeSupervisorClient } from "../../intercom/native-supervisor-channel.ts";
5
+ import { consumeSteerRequestsFromDir, writeSteerRequestToDir, type SteerRequest } from "../background/control-channel.ts";
6
+ import { SUBAGENT_FANOUT_CHILD_ENV, SUBAGENT_STEER_INBOX_ENV } from "./pi-args.ts";
5
7
  import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV, validateStructuredOutputValue } from "./structured-output.ts";
6
- import type { JsonSchemaObject } from "../../shared/types.ts";
8
+ import { TOOL_BUDGET_ENV, decodeToolBudgetEnv, shouldBlockToolForBudget, toolBudgetBlockedMessage, toolBudgetSoftNudge } from "./tool-budget.ts";
9
+ import type { JsonSchemaObject, ResolvedToolBudget } from "../../shared/types.ts";
7
10
 
8
11
  const SUBAGENT_INHERIT_PROJECT_CONTEXT_ENV = "PI_SUBAGENT_INHERIT_PROJECT_CONTEXT";
9
12
  const SUBAGENT_INHERIT_SKILLS_ENV = "PI_SUBAGENT_INHERIT_SKILLS";
@@ -155,7 +158,110 @@ export function stripParentOnlySubagentMessages(messages: unknown[]): unknown[]
155
158
  return changed ? filtered : messages;
156
159
  }
157
160
 
161
+ export function formatSteerMessage(request: SteerRequest): string {
162
+ return [
163
+ "Mid-run steering from the parent orchestrator:",
164
+ "",
165
+ request.message,
166
+ "",
167
+ "Incorporate this guidance at the next safe point. Do not restart the task unless the guidance explicitly asks you to.",
168
+ ].join("\n");
169
+ }
170
+
171
+ function registerToolBudget(pi: ExtensionAPI, budget: ResolvedToolBudget | undefined): void {
172
+ if (!budget) return;
173
+ let toolCount = 0;
174
+ let softNudged = false;
175
+ const sendUserMessage = (pi as { sendUserMessage?: (content: string, options: { deliverAs: "steer" }) => unknown }).sendUserMessage;
176
+ const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: { toolName?: string }) => unknown) => void;
177
+ onRuntimeEvent("tool_call", (event) => {
178
+ const toolName = typeof event.toolName === "string" ? event.toolName : "tool";
179
+ toolCount++;
180
+ if (budget.soft !== undefined && toolCount >= budget.soft && !softNudged) {
181
+ softNudged = true;
182
+ try {
183
+ sendUserMessage?.(toolBudgetSoftNudge(budget, toolCount), { deliverAs: "steer" });
184
+ } catch {
185
+ // Budget nudges are advisory; blocking below remains authoritative.
186
+ }
187
+ }
188
+ if (!shouldBlockToolForBudget(budget, toolName, toolCount)) return undefined;
189
+ return { block: true, reason: toolBudgetBlockedMessage(budget, toolName, toolCount) };
190
+ });
191
+ }
192
+
193
+ function registerSteeringInbox(pi: ExtensionAPI): void {
194
+ const steerInbox = process.env[SUBAGENT_STEER_INBOX_ENV]?.trim();
195
+ if (!steerInbox) return;
196
+ const sendUserMessage = (pi as { sendUserMessage?: (content: string, options: { deliverAs: "steer" }) => unknown }).sendUserMessage;
197
+ if (typeof sendUserMessage !== "function") return;
198
+
199
+ let canSteer = false;
200
+ let disposed = false;
201
+ let flushing = false;
202
+ let started = false;
203
+ let watcher: fs.FSWatcher | undefined;
204
+ let interval: NodeJS.Timeout | undefined;
205
+ const flush = (): void => {
206
+ if (disposed || flushing || !canSteer) return;
207
+ flushing = true;
208
+ try {
209
+ const requests = consumeSteerRequestsFromDir(steerInbox);
210
+ for (let index = 0; index < requests.length; index++) {
211
+ const request = requests[index]!;
212
+ try {
213
+ sendUserMessage(formatSteerMessage(request), { deliverAs: "steer" });
214
+ } catch {
215
+ for (const pending of requests.slice(index)) writeSteerRequestToDir(steerInbox, pending);
216
+ break;
217
+ }
218
+ }
219
+ } finally {
220
+ flushing = false;
221
+ }
222
+ };
223
+ const start = (): void => {
224
+ if (started || disposed) return;
225
+ try {
226
+ fs.mkdirSync(steerInbox, { recursive: true });
227
+ } catch {
228
+ return;
229
+ }
230
+ started = true;
231
+ try {
232
+ watcher = fs.watch(steerInbox, () => flush());
233
+ watcher.on("error", () => {});
234
+ } catch {
235
+ watcher = undefined;
236
+ }
237
+ interval = setInterval(flush, 250);
238
+ interval.unref?.();
239
+ };
240
+ const activate = (): undefined => {
241
+ start();
242
+ canSteer = true;
243
+ flush();
244
+ return undefined;
245
+ };
246
+
247
+ const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown) => unknown) => void;
248
+ onRuntimeEvent("session_start", () => start());
249
+ for (const eventName of ["message_start", "message_update", "message_end", "tool_execution_start", "tool_execution_end", "turn_end"] as const) {
250
+ onRuntimeEvent(eventName, activate);
251
+ }
252
+ onRuntimeEvent("session_shutdown", () => {
253
+ disposed = true;
254
+ try {
255
+ watcher?.close();
256
+ } catch {}
257
+ if (interval) clearInterval(interval);
258
+ });
259
+ }
260
+
158
261
  export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
262
+ registerSteeringInbox(pi);
263
+ registerToolBudget(pi, decodeToolBudgetEnv(process.env[TOOL_BUDGET_ENV]));
264
+ registerNativeSupervisorClient(pi);
159
265
  const structuredOutputPath = process.env[STRUCTURED_OUTPUT_CAPTURE_ENV];
160
266
  const structuredSchemaPath = process.env[STRUCTURED_OUTPUT_SCHEMA_ENV];
161
267
  if (structuredOutputPath && structuredSchemaPath) {