pi-subagents 0.51.0 → 0.52.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 (53) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/agents/oracle.md +3 -1
  3. package/agents/reviewer.md +1 -0
  4. package/agents/scout.md +2 -2
  5. package/agents/worker.md +2 -1
  6. package/docs/agents.md +8 -3
  7. package/docs/extension-api.md +1 -1
  8. package/docs/models.md +2 -0
  9. package/docs/observability.md +37 -1
  10. package/docs/workflows.md +2 -0
  11. package/package.json +1 -1
  12. package/skills/pi-subagents/SKILL.md +1 -0
  13. package/skills/pi-subagents/references/constraints-and-recipes.md +6 -2
  14. package/skills/pi-subagents/references/execution-controls.md +12 -1
  15. package/skills/pi-subagents/references/prompting-and-roles.md +2 -0
  16. package/src/agents/agent-management.ts +24 -6
  17. package/src/agents/agents.ts +37 -11
  18. package/src/agents/skills.ts +1 -1
  19. package/src/api/preflight.ts +5 -2
  20. package/src/extension/index.ts +1 -0
  21. package/src/extension/public-execution.ts +3 -1
  22. package/src/intercom/native-supervisor-channel.ts +2 -1
  23. package/src/missions/workflow-state.ts +2 -2
  24. package/src/runs/background/async-execution.ts +19 -9
  25. package/src/runs/background/async-resume.ts +19 -2
  26. package/src/runs/background/async-retention.ts +2 -4
  27. package/src/runs/background/completion-dedupe.ts +5 -1
  28. package/src/runs/background/completion-replay.ts +22 -12
  29. package/src/runs/background/fleet-view.ts +68 -19
  30. package/src/runs/background/inspect-rpc.ts +443 -0
  31. package/src/runs/background/notify.ts +28 -4
  32. package/src/runs/background/result-watcher.ts +44 -0
  33. package/src/runs/background/resume-guidance.ts +8 -5
  34. package/src/runs/background/scheduled-runs.ts +2 -0
  35. package/src/runs/background/subagent-runner.ts +3 -2
  36. package/src/runs/background/subagent-wait.ts +1 -1
  37. package/src/runs/background/wait-subscriptions.ts +1 -1
  38. package/src/runs/foreground/execution.ts +1 -1
  39. package/src/runs/foreground/subagent-executor.ts +239 -76
  40. package/src/runs/foreground/workflow-detach-reconcile.ts +194 -0
  41. package/src/runs/shared/acceptance.ts +4 -4
  42. package/src/runs/shared/external-job-bridge.ts +0 -6
  43. package/src/runs/shared/model-fallback.ts +77 -34
  44. package/src/runs/shared/session-lease.ts +0 -6
  45. package/src/runs/shared/worktree.ts +2 -1
  46. package/src/shared/types.ts +11 -0
  47. package/src/slash/slash-bridge.ts +2 -1
  48. package/src/slash/slash-commands.ts +18 -0
  49. package/src/watchdog/change-signature.ts +1 -1
  50. package/src/watchdog/lsp-diagnostics.ts +1 -0
  51. package/src/workflows/chat-progress.ts +1 -1
  52. package/src/workflows/scripted-workflow.ts +13 -2
  53. package/agents/gpt-pro.md +0 -17
@@ -0,0 +1,194 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { writeAtomicJson } from "../../shared/atomic-json.ts";
4
+ import { isStorageCapacityError } from "../../shared/file-system-retry.ts";
5
+ import { getSingleResultOutput, readStatus } from "../../shared/utils.ts";
6
+ import {
7
+ DIRS,
8
+ SUBAGENT_ASYNC_COMPLETE_EVENT,
9
+ type AsyncStatus,
10
+ type IntercomEventBus,
11
+ type SingleResult,
12
+ type SubagentState,
13
+ } from "../../shared/types.ts";
14
+ import { updateActiveRunIndex } from "../background/active-run-index.ts";
15
+ import { resultFilePath, writeAsyncResultFile } from "../background/result-files.ts";
16
+
17
+ function cloneWorkflowStatus(status: AsyncStatus): AsyncStatus {
18
+ return {
19
+ ...status,
20
+ steps: status.steps?.map((step) => ({ ...step })),
21
+ workflow: status.workflow ? { ...status.workflow, trace: [...(status.workflow.trace ?? [])] } : status.workflow,
22
+ };
23
+ }
24
+
25
+ function childSucceeded(result: Pick<SingleResult, "exitCode" | "error">): boolean {
26
+ return result.exitCode === 0 && !result.error;
27
+ }
28
+
29
+ export function applyDetachedChildToPausedWorkflow(
30
+ status: AsyncStatus,
31
+ input: { childRunId: string; result: Pick<SingleResult, "exitCode" | "error" | "sessionFile">; workflowKey?: string },
32
+ ): AsyncStatus | undefined {
33
+ if (status.mode !== "workflow" || status.state !== "paused") return undefined;
34
+ const next = cloneWorkflowStatus(status);
35
+ const step = next.steps?.find((candidate) => candidate.runId === input.childRunId)
36
+ ?? (input.workflowKey ? next.steps?.find((candidate) => candidate.workflowKey === input.workflowKey) : undefined);
37
+ if (!step) return undefined;
38
+ const succeeded = childSucceeded(input.result);
39
+ const updatedAt = Date.now();
40
+ step.status = succeeded ? "completed" : "failed";
41
+ step.endedAt = updatedAt;
42
+ delete step.activityState;
43
+ delete step.currentTool;
44
+ delete step.currentToolStartedAt;
45
+ if (input.result.sessionFile) step.sessionFile = input.result.sessionFile;
46
+ if (succeeded) delete step.error;
47
+ else if (input.result.error) step.error = input.result.error;
48
+ next.lastUpdate = updatedAt;
49
+ const promoted = promotePausedWorkflowIfSettled(next);
50
+ if (promoted?.state === "failed" && input.result.error) promoted.error = input.result.error;
51
+ return promoted ?? next;
52
+ }
53
+
54
+ export function promotePausedWorkflowIfSettled(status: AsyncStatus): AsyncStatus | undefined {
55
+ if (status.mode !== "workflow" || status.state !== "paused") return undefined;
56
+ const next = cloneWorkflowStatus(status);
57
+ const stillOpen = next.steps?.some((candidate) =>
58
+ candidate.status === "running"
59
+ || (candidate.status === "paused" && candidate.activityState === "needs_attention")
60
+ ) === true;
61
+ if (stillOpen || !next.steps?.length) return undefined;
62
+ const failed = next.steps.some((candidate) => candidate.status === "failed");
63
+ const updatedAt = Date.now();
64
+ next.lastUpdate = updatedAt;
65
+ next.state = failed ? "failed" : "complete";
66
+ next.endedAt = updatedAt;
67
+ delete next.activityState;
68
+ if (!failed) delete next.error;
69
+ return next;
70
+ }
71
+
72
+ function workflowResultChildren(status: AsyncStatus, childRunId: string, result: SingleResult, existingResults: unknown): unknown {
73
+ const output = getSingleResultOutput(result);
74
+ if (Array.isArray(existingResults)) {
75
+ return existingResults.map((entry) => {
76
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return entry;
77
+ const child = entry as Record<string, unknown>;
78
+ if (child.runId !== childRunId) return child;
79
+ return {
80
+ ...child,
81
+ success: childSucceeded(result),
82
+ output,
83
+ outputState: output.trim() ? "present" : "absent",
84
+ detached: undefined,
85
+ ...(result.error ? { error: result.error } : {}),
86
+ };
87
+ });
88
+ }
89
+ return status.steps?.map((step) => ({
90
+ workflowKey: step.workflowKey,
91
+ agent: step.agent,
92
+ runId: step.runId,
93
+ success: step.status === "completed" || step.status === "complete",
94
+ output: step.runId === childRunId ? output : "",
95
+ outputState: step.runId === childRunId && output.trim() ? "present" : "absent",
96
+ ...(step.error ? { error: step.error } : {}),
97
+ }));
98
+ }
99
+
100
+ function publishedWorkflowResult(status: AsyncStatus, childRunId: string, result: SingleResult, asyncDir: string, existing?: Record<string, unknown>): Record<string, unknown> {
101
+ const sessionId = status.sessionId ?? (typeof existing?.sessionId === "string" ? existing.sessionId : undefined);
102
+ const summary = status.state === "complete"
103
+ ? `Workflow completed after detached child ${childRunId} finished.`
104
+ : status.error ?? (typeof existing?.summary === "string" ? existing.summary : undefined);
105
+ return {
106
+ ...(existing ?? {}),
107
+ id: status.runId,
108
+ runId: status.runId,
109
+ toolCallId: status.toolCallId,
110
+ agent: "workflow",
111
+ mode: "workflow",
112
+ success: status.state === "complete",
113
+ state: status.state,
114
+ summary,
115
+ error: status.error,
116
+ activityState: status.activityState,
117
+ endedAt: status.endedAt,
118
+ timestamp: Date.now(),
119
+ results: workflowResultChildren(status, childRunId, result, existing?.results),
120
+ workflow: status.workflow,
121
+ asyncDir,
122
+ cwd: status.cwd,
123
+ sessionId,
124
+ completionOwnerId: status.completionOwnerId,
125
+ };
126
+ }
127
+
128
+ export function reconcileDetachedWorkflowChildCompletion(input: {
129
+ state: SubagentState;
130
+ workflowRunId: string;
131
+ childRunId: string;
132
+ result: SingleResult;
133
+ events?: IntercomEventBus;
134
+ workflowKey?: string;
135
+ }): boolean {
136
+ const job = input.state.asyncJobs.get(input.workflowRunId);
137
+ const asyncDir = job?.asyncDir ?? path.join(DIRS.async, input.workflowRunId);
138
+ const status = readStatus(asyncDir);
139
+ if (!status) return false;
140
+ const next = applyDetachedChildToPausedWorkflow(status, {
141
+ childRunId: input.childRunId,
142
+ result: input.result,
143
+ workflowKey: input.workflowKey,
144
+ });
145
+ if (!next) return false;
146
+ writeAtomicJson(path.join(asyncDir, "status.json"), next);
147
+ updateActiveRunIndex(asyncDir, next.state, next.toolCallId);
148
+ if (job) {
149
+ job.status = next.state;
150
+ job.updatedAt = next.lastUpdate;
151
+ job.activityState = next.activityState;
152
+ job.steps = next.steps?.map((step, index) => ({ ...step, index }));
153
+ job.workflow = next.workflow;
154
+ }
155
+ const resultPath = resultFilePath(DIRS.results, input.workflowRunId);
156
+ let existing: Record<string, unknown> | undefined;
157
+ try {
158
+ const parsed: unknown = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
159
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) existing = parsed as Record<string, unknown>;
160
+ } catch (error) {
161
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
162
+ }
163
+ const published = publishedWorkflowResult(next, input.childRunId, input.result, asyncDir, existing);
164
+ writeAsyncResultFile(resultPath, published);
165
+ if (next.state === "complete" || next.state === "failed") {
166
+ try {
167
+ fs.appendFileSync(path.join(asyncDir, "events.jsonl"), `${JSON.stringify({
168
+ ts: Date.now(),
169
+ runId: input.workflowRunId,
170
+ type: "subagent.workflow.completed",
171
+ state: next.state,
172
+ ...(next.error ? { error: next.error } : {}),
173
+ reconciledFromDetachedChild: input.childRunId,
174
+ })}\n`, "utf-8");
175
+ } catch (error) {
176
+ if (!isStorageCapacityError(error)) throw error;
177
+ }
178
+ input.events?.emit(SUBAGENT_ASYNC_COMPLETE_EVENT, {
179
+ id: input.workflowRunId,
180
+ runId: input.workflowRunId,
181
+ source: "async",
182
+ mode: "workflow",
183
+ agent: "workflow",
184
+ success: next.state === "complete",
185
+ state: next.state,
186
+ summary: typeof published.summary === "string" ? published.summary : (next.state === "complete" ? "Workflow completed." : next.error),
187
+ sessionId: next.sessionId,
188
+ completionOwnerId: next.completionOwnerId,
189
+ timestamp: Date.now(),
190
+ triggerTurn: true,
191
+ });
192
+ }
193
+ return true;
194
+ }
@@ -946,7 +946,7 @@ function reportEvidenceStatus(report: AcceptanceReport, kind: AcceptanceEvidence
946
946
  }
947
947
 
948
948
  function checkNoStagedFiles(cwd: string): AcceptanceRuntimeCheck {
949
- const result = spawnSync("git", ["status", "--short"], { cwd, encoding: "utf-8" });
949
+ const result = spawnSync("git", ["status", "--short"], { cwd, encoding: "utf-8", windowsHide: true });
950
950
  if (result.status !== 0) {
951
951
  return { id: "no-staged-files", status: "not-applicable", message: "git status unavailable; no staged-files check skipped" };
952
952
  }
@@ -1053,11 +1053,11 @@ interface VerifyWorkspaceState {
1053
1053
  }
1054
1054
 
1055
1055
  function readVerifyWorkspaceState(cwd: string): VerifyWorkspaceState | undefined {
1056
- const repo = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf-8" });
1056
+ const repo = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf-8", windowsHide: true });
1057
1057
  if (repo.status !== 0 || !repo.stdout.trim()) return undefined;
1058
1058
  const repoRoot = fs.realpathSync(repo.stdout.trim());
1059
- const head = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf-8" });
1060
- const diff = spawnSync("git", ["diff", "--binary", "--full-index", "HEAD", "--"], { cwd: repoRoot, encoding: "utf-8", maxBuffer: 50 * 1024 * 1024 });
1059
+ const head = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf-8", windowsHide: true });
1060
+ const diff = spawnSync("git", ["diff", "--binary", "--full-index", "HEAD", "--"], { cwd: repoRoot, encoding: "utf-8", maxBuffer: 50 * 1024 * 1024, windowsHide: true });
1061
1061
  if (head.status !== 0 || diff.status !== 0 || !head.stdout.trim()) return undefined;
1062
1062
  return {
1063
1063
  kind: "git-tracked",
@@ -1,5 +1,4 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { spawnSync } from "node:child_process";
3
2
  import * as fs from "node:fs";
4
3
  import * as os from "node:os";
5
4
  import * as path from "node:path";
@@ -134,11 +133,6 @@ function processStartIdentity(pid: number): string | undefined {
134
133
  return undefined;
135
134
  }
136
135
  }
137
- if (process.platform === "darwin" || process.platform === "freebsd") {
138
- const result = spawnSync("/bin/ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf-8" });
139
- const started = result.status === 0 ? result.stdout.trim() : "";
140
- return started ? `${process.platform}:${started}` : undefined;
141
- }
142
136
  return undefined;
143
137
  }
144
138
 
@@ -41,7 +41,7 @@ export function normalizeParentModel(model: unknown): ParentModel | undefined {
41
41
  /**
42
42
  * Normalize a model id or provider segment for fuzzy comparison: case-fold,
43
43
  * treat dots/underscores as dashes (so `4.5` matches `4-5`), and collapse
44
- * repeated separators. Pure.
44
+ * repeated separators.
45
45
  */
46
46
  export function normalizeModelSegment(segment: string): string {
47
47
  return segment
@@ -58,7 +58,7 @@ function isPlausibleDateStamp(year: string, month: string, day: string): boolean
58
58
  return yyyy >= 1900 && yyyy <= 2099 && mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31;
59
59
  }
60
60
 
61
- /** Drop a trailing date stamp (`-20251001` or `-2025-10-01`) so dated and undated ids match. Pure. */
61
+ /** Drop a trailing date stamp (`-20251001` or `-2025-10-01`) so dated and undated ids match. */
62
62
  function stripTrailingDateStamp(segment: string): string {
63
63
  const dashed = /^(.*)-(\d{4})-(\d{2})-(\d{2})$/.exec(segment);
64
64
  if (dashed && isPlausibleDateStamp(dashed[2]!, dashed[3]!, dashed[4]!)) return dashed[1]!;
@@ -67,21 +67,65 @@ function stripTrailingDateStamp(segment: string): string {
67
67
  return segment;
68
68
  }
69
69
 
70
+ function isRegisteredProvider(provider: string, availableModels: AvailableModelInfo[]): boolean {
71
+ const normalized = normalizeModelSegment(provider);
72
+ return availableModels.some((entry) => normalizeModelSegment(entry.provider) === normalized);
73
+ }
74
+
75
+ /**
76
+ * Split `provider/id` only when the first path segment is a registered provider.
77
+ * Hugging Face-style `owner/name` ids therefore stay intact unless `owner` is
78
+ * itself a provider in the active registry. `:` and `.` keep the same rule.
79
+ */
80
+ function splitQualifiedModelQuery(
81
+ baseModel: string,
82
+ availableModels: AvailableModelInfo[],
83
+ ): { queryProvider?: string; queryIdRaw: string } {
84
+ const slashIdx = baseModel.indexOf("/");
85
+ if (slashIdx !== -1) {
86
+ const providerPart = baseModel.slice(0, slashIdx);
87
+ if (isRegisteredProvider(providerPart, availableModels)) {
88
+ return { queryProvider: normalizeModelSegment(providerPart), queryIdRaw: baseModel.slice(slashIdx + 1) };
89
+ }
90
+ return { queryIdRaw: baseModel };
91
+ }
92
+ const providerSeparators = [":", "."];
93
+ for (const separator of providerSeparators) {
94
+ const separatorIdx = baseModel.indexOf(separator);
95
+ if (separatorIdx <= 0) continue;
96
+ const providerPart = baseModel.slice(0, separatorIdx);
97
+ if (!isRegisteredProvider(providerPart, availableModels)) continue;
98
+ return { queryProvider: normalizeModelSegment(providerPart), queryIdRaw: baseModel.slice(separatorIdx + 1) };
99
+ }
100
+ return { queryIdRaw: baseModel };
101
+ }
102
+
103
+ function resolveExactIdMatches(
104
+ baseModel: string,
105
+ availableModels: AvailableModelInfo[],
106
+ preferredProvider?: string,
107
+ ): string | undefined {
108
+ const exactMatches = availableModels.filter((entry) => entry.id === baseModel);
109
+ if (preferredProvider) {
110
+ const preferredMatch = exactMatches.find((entry) => entry.provider === preferredProvider);
111
+ if (preferredMatch) return preferredMatch.fullId;
112
+ }
113
+ if (exactMatches.length === 1) return exactMatches[0]!.fullId;
114
+ return undefined;
115
+ }
116
+
70
117
  function resolveBaseModelCandidate(
71
118
  baseModel: string,
72
119
  availableModels: AvailableModelInfo[],
73
120
  preferredProvider?: string,
74
121
  ): string | undefined {
75
- if (baseModel.includes("/")) {
76
- const exact = availableModels.find((entry) => entry.fullId === baseModel);
77
- if (exact) return exact.fullId;
78
- } else {
79
- const exactMatches = availableModels.filter((entry) => entry.id === baseModel);
80
- if (preferredProvider) {
81
- const preferredMatch = exactMatches.find((entry) => entry.provider === preferredProvider);
82
- if (preferredMatch) return preferredMatch.fullId;
83
- }
84
- if (exactMatches.length === 1) return exactMatches[0]!.fullId;
122
+ const exact = availableModels.find((entry) => entry.fullId === baseModel);
123
+ if (exact) return exact.fullId;
124
+
125
+ const { queryProvider } = splitQualifiedModelQuery(baseModel, availableModels);
126
+ if (queryProvider === undefined) {
127
+ const exactId = resolveExactIdMatches(baseModel, availableModels, preferredProvider);
128
+ if (exactId) return exactId;
85
129
  }
86
130
 
87
131
  return fuzzyResolveModel(baseModel, availableModels, preferredProvider);
@@ -90,35 +134,20 @@ function resolveBaseModelCandidate(
90
134
  /**
91
135
  * Fuzzy-resolve a base model id (thinking suffix already stripped) against the
92
136
  * registry, tolerating separator, case, and optional date-stamp differences so
93
- * users do not have to spell provider/model exactly. A qualified `provider/id`
137
+ * users do not have to spell provider/model exactly. A slash is a provider
138
+ * prefix only when that prefix is a registered provider; otherwise the whole
139
+ * string is the model id (Hugging Face `owner/name`). A qualified provider
94
140
  * query only matches within the named provider — this never silently switches
95
141
  * providers for security/cost-sensitive configs. Returns the matched `fullId`,
96
142
  * or `undefined` when there is no match or the match is ambiguous across
97
- * providers (and no `preferredProvider` disambiguates). Pure.
143
+ * providers (and no `preferredProvider` disambiguates).
98
144
  */
99
145
  export function fuzzyResolveModel(
100
146
  baseModel: string,
101
147
  availableModels: AvailableModelInfo[],
102
148
  preferredProvider?: string,
103
149
  ): string | undefined {
104
- let queryProvider: string | undefined;
105
- let queryIdRaw = baseModel;
106
- const slashIdx = baseModel.indexOf("/");
107
- if (slashIdx !== -1) {
108
- queryProvider = normalizeModelSegment(baseModel.slice(0, slashIdx));
109
- queryIdRaw = baseModel.slice(slashIdx + 1);
110
- } else {
111
- const providerSeparators = [":", "."];
112
- for (const separator of providerSeparators) {
113
- const separatorIdx = baseModel.indexOf(separator);
114
- if (separatorIdx <= 0) continue;
115
- const providerPart = normalizeModelSegment(baseModel.slice(0, separatorIdx));
116
- if (!availableModels.some((entry) => normalizeModelSegment(entry.provider) === providerPart)) continue;
117
- queryProvider = providerPart;
118
- queryIdRaw = baseModel.slice(separatorIdx + 1);
119
- break;
120
- }
121
- }
150
+ const { queryProvider, queryIdRaw } = splitQualifiedModelQuery(baseModel, availableModels);
122
151
  const queryId = normalizeModelSegment(queryIdRaw);
123
152
  const queryIdNoDate = stripTrailingDateStamp(queryId);
124
153
 
@@ -143,7 +172,7 @@ export function fuzzyResolveModel(
143
172
  * thinking suffix). Exact registry matches win; fuzzy normalization
144
173
  * (separator/case/date-stamp via {@link fuzzyResolveModel}) is a fallback so
145
174
  * spelling differences still resolve. Never switches providers for a qualified
146
- * query. Pure.
175
+ * query.
147
176
  */
148
177
  export function resolveModelCandidate(
149
178
  model: string | undefined,
@@ -272,6 +301,18 @@ export interface BuildModelCandidatesOptions {
272
301
  /** Fallback models warn by default and throw when strict scope enforcement is enabled. */
273
302
  scope?: ModelScopeConfig;
274
303
  onWarn?: (violation: ModelScopeViolation) => void;
304
+ /** The primary model came from the running parent session, not configuration. */
305
+ primaryModelFromParent?: boolean;
306
+ }
307
+
308
+ export function inheritsParentModel(
309
+ explicitModel: string | boolean | undefined,
310
+ agentModel: string | boolean | undefined,
311
+ parentModel: ParentModel | undefined,
312
+ ): boolean {
313
+ const requestedModel = explicitModel ?? agentModel;
314
+ const trimmed = typeof requestedModel === "string" ? requestedModel.trim() : "";
315
+ return Boolean(parentModel && (!trimmed || trimmed === INHERIT_MODEL));
275
316
  }
276
317
 
277
318
  export function buildModelCandidates(
@@ -289,7 +330,9 @@ export function buildModelCandidates(
289
330
  if (!raw) continue;
290
331
  const model = raw.trim();
291
332
  const normalized = index === 0
292
- ? resolveRequiredSubagentModelCandidate(model, availableModels, preferredProvider)
333
+ ? options?.primaryModelFromParent
334
+ ? model
335
+ : resolveRequiredSubagentModelCandidate(model, availableModels, preferredProvider)
293
336
  : resolveSubagentModelCandidate(model, availableModels, preferredProvider);
294
337
  if (!normalized) {
295
338
  console.warn(`[pi-subagents] Skipping fallback model '${model}' because it is unavailable in this environment.`);
@@ -1,4 +1,3 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import { createHash, randomUUID } from "node:crypto";
3
2
  import * as fs from "node:fs";
4
3
  import * as os from "node:os";
@@ -79,11 +78,6 @@ function getProcessStartIdentity(pid: number): string | undefined {
79
78
  return undefined;
80
79
  }
81
80
  }
82
- if (process.platform === "darwin" || process.platform === "freebsd") {
83
- const result = spawnSync("/bin/ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf-8" });
84
- const started = result.status === 0 ? result.stdout.trim() : "";
85
- return started ? `${process.platform}:${started}` : undefined;
86
- }
87
81
  return undefined;
88
82
  }
89
83
 
@@ -115,7 +115,7 @@ interface RepoState {
115
115
  const DEFAULT_WORKTREE_SETUP_HOOK_TIMEOUT_MS = 30000;
116
116
 
117
117
  function runGit(cwd: string, args: string[]): GitResult {
118
- const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8" });
118
+ const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", windowsHide: true });
119
119
  return {
120
120
  stdout: result.stdout ?? "",
121
121
  stderr: result.stderr ?? "",
@@ -338,6 +338,7 @@ function runWorktreeSetupHook(
338
338
  input: WorktreeSetupHookInput,
339
339
  ): string[] {
340
340
  const result = spawnSync(hook.hookPath, [], {
341
+ windowsHide: true,
341
342
  cwd: input.worktreePath,
342
343
  encoding: "utf-8",
343
344
  input: JSON.stringify(input),
@@ -409,6 +409,12 @@ export type ProcessTerminalV1 =
409
409
  diagnostic?: string;
410
410
  });
411
411
 
412
+ /** Identifies the durable schedule that launched a run, so its completion is attributable. */
413
+ export interface ScheduleOrigin {
414
+ id: string;
415
+ name?: string;
416
+ }
417
+
412
418
  export type SteeringActionState = "delivered" | "scheduled" | "pending" | "partial" | "recovered" | "failed";
413
419
  export type SteeringTargetState = "scheduled" | "pending" | "routed" | "queued" | "delivered" | "late" | "failed" | "recovered";
414
420
 
@@ -502,6 +508,7 @@ export interface SteeringRecoveryDescriptor {
502
508
  sessionFile?: string;
503
509
  cwd: string;
504
510
  model?: string;
511
+ modelOverrideFromParent?: boolean;
505
512
  fallbackModels?: string[];
506
513
  thinking?: string;
507
514
  tools?: string[];
@@ -1408,6 +1415,8 @@ export interface AsyncStatus {
1408
1415
  workflow?: Details["workflow"];
1409
1416
  parentWorkflowRunId?: string;
1410
1417
  workflowKey?: string;
1418
+ /** Set when a durable schedule launched this run, so completions can name their origin. */
1419
+ scheduleOrigin?: ScheduleOrigin;
1411
1420
  steps?: Array<{
1412
1421
  agent: string;
1413
1422
  runner?: ExternalCliRunnerStatus | ExternalJobRunnerStatus;
@@ -1842,6 +1851,8 @@ export interface RunSyncOptions {
1842
1851
  nestedRoute?: NestedRouteInfo;
1843
1852
  /** Override the agent's default model (format: "provider/id" or just "id") */
1844
1853
  modelOverride?: string;
1854
+ /** The override came from the running parent session, not configuration. */
1855
+ modelOverrideFromParent?: boolean;
1845
1856
  /** LLM intent arbiter for the completion mutation guard (rescues read-only review runs). */
1846
1857
  llmIntentArbiter?: import("../runs/shared/llm-intent-arbiter.ts").TaskMutationArbiter;
1847
1858
  /** Override the agent's default thinking level for this run */
@@ -39,6 +39,7 @@ interface EventBus {
39
39
 
40
40
  interface SlashBridgeOptions {
41
41
  events: EventBus;
42
+ asyncByDefault: boolean;
42
43
  getContext: () => ExtensionContext | null;
43
44
  execute: (
44
45
  id: string,
@@ -79,7 +80,7 @@ export function registerSlashSubagentBridge(options: SlashBridgeOptions): {
79
80
  const request = data as Partial<SlashSubagentRequest>;
80
81
  if (typeof request.requestId !== "string" || !request.params) return;
81
82
  const { requestId } = request as SlashSubagentRequest;
82
- const normalized = normalizePublicSubagentExecution((request as SlashSubagentRequest).params);
83
+ const normalized = normalizePublicSubagentExecution((request as SlashSubagentRequest).params, { asyncByDefault: options.asyncByDefault });
83
84
  if (!normalized.ok) {
84
85
  options.events.emit(SLASH_SUBAGENT_RESPONSE_EVENT, {
85
86
  requestId,
@@ -18,6 +18,7 @@ import type { SubagentParamsLike } from "../runs/foreground/subagent-executor.ts
18
18
  import { findModelInfo, toModelInfo } from "../shared/model-info.ts";
19
19
  import { formatTokens, shortenPath } from "../shared/formatters.ts";
20
20
  import { listAsyncRuns, formatAsyncRunProgressLabel, type AsyncRunSummary } from "../runs/background/async-status.ts";
21
+ import { encodeInspectReply, handleInspectRpcArgs, INSPECT_WIDGET_KEY } from "../runs/background/inspect-rpc.ts";
21
22
  import { listScheduledRunSummaries } from "../runs/background/scheduled-runs.ts";
22
23
  import { SUBAGENT_FANOUT_CHILD_ENV } from "../runs/shared/pi-args.ts";
23
24
  import type { SlashSubagentResponse, SlashSubagentUpdate } from "./slash-bridge.ts";
@@ -711,6 +712,23 @@ export function registerSlashCommands(
711
712
  },
712
713
  });
713
714
 
715
+ pi.registerCommand("subagents-inspect-rpc", {
716
+ description: "Host integration bridge: answer an async child inspection request with a correlated widget payload (no model turn)",
717
+ handler: async (args, ctx) => {
718
+ if (ctx.mode === "tui") {
719
+ ctx.ui.notify("Inspection replies are emitted only on RPC surfaces. Use /subagents or subagent({ action: \"status\", view: \"transcript\" }) interactively.", "info");
720
+ return;
721
+ }
722
+ if (!ctx.hasUI) return;
723
+ const reply = handleInspectRpcArgs(args, { state });
724
+ // Emit-then-retract in one handler: stdio delivers the two widget
725
+ // updates in order, the host buffers the payload by requestId, and
726
+ // the dedicated key never accumulates visible state.
727
+ ctx.ui.setWidget(INSPECT_WIDGET_KEY, encodeInspectReply(reply));
728
+ ctx.ui.setWidget(INSPECT_WIDGET_KEY, undefined);
729
+ },
730
+ });
731
+
714
732
  pi.registerCommand("subagents-guide", {
715
733
  description: "Show a packaged subagents guide topic",
716
734
  getArgumentCompletions: (prefix) => prefix.includes(" ") ? null : SUBAGENT_GUIDE_TOPICS
@@ -38,7 +38,7 @@ export interface WatchdogRepoChangeSignature {
38
38
  }
39
39
 
40
40
  function git(cwd: string, args: string[]): string | undefined {
41
- const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 });
41
+ const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", maxBuffer: 10 * 1024 * 1024, windowsHide: true });
42
42
  if (result.status !== 0) return undefined;
43
43
  return result.stdout;
44
44
  }
@@ -444,6 +444,7 @@ async function collectWithTypeScriptLanguageServer(input: {
444
444
  }): Promise<WatchdogLspResult> {
445
445
  const started = Date.now();
446
446
  const child = spawn(input.command.command, input.command.args, {
447
+ windowsHide: true,
447
448
  cwd: input.root,
448
449
  stdio: "pipe",
449
450
  env: { ...process.env, NO_COLOR: "1" },
@@ -26,7 +26,7 @@ interface ResolveWorkflowChatProgressInput {
26
26
  }
27
27
 
28
28
  function git(cwd: string, args: string[]): string | undefined {
29
- const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8" });
29
+ const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8", windowsHide: true });
30
30
  if (result.status !== 0) return undefined;
31
31
  const output = result.stdout.trim();
32
32
  return output || undefined;
@@ -758,7 +758,18 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
758
758
  const child = children.get(key);
759
759
  return child ? [child] : [];
760
760
  }) });
761
- const traceChanged = () => options.onTrace?.([...trace]);
761
+ // Hosts use onTrace to persist a progress journal, and it is invoked from inside
762
+ // the run-promise handlers below. A throw here would reject the child promise the
763
+ // script is awaiting, so a single failed status write could mark a completed child
764
+ // failed and abort its siblings through Promise.all. Telemetry must not decide
765
+ // workflow outcomes, so a failing callback is reported and the run continues.
766
+ const traceChanged = () => {
767
+ try {
768
+ options.onTrace?.([...trace]);
769
+ } catch (error) {
770
+ console.error("Workflow onTrace callback failed:", error);
771
+ }
772
+ };
762
773
 
763
774
  return await new Promise<WorkflowScriptResult>((resolve, reject) => {
764
775
  const finish = (outcome: { value: unknown } | { error: Error & { workflowErrorKind?: unknown } }) => {
@@ -1012,7 +1023,7 @@ export async function runWorkflowScript(options: RunWorkflowScriptOptions): Prom
1012
1023
  if (!admission) {
1013
1024
  const seenKeys = new Set<string>();
1014
1025
  const calls = (batch?.calls ?? [{ key, params }]).filter((call) => {
1015
- if (seenKeys.has(call.key) || launches.has(call.key) || call.params.resume !== undefined) return false;
1026
+ if (seenKeys.has(call.key) || launches.has(call.key)) return false;
1016
1027
  seenKeys.add(call.key);
1017
1028
  return true;
1018
1029
  });
package/agents/gpt-pro.md DELETED
@@ -1,17 +0,0 @@
1
- ---
2
- name: gpt-pro
3
- description: Surf GPT Pro advisory runner through the external-job provider bridge
4
- runner:
5
- type: external-job
6
- provider: surf-oracle
7
- async: true
8
- systemPromptMode: replace
9
- inheritProjectContext: false
10
- inheritSkills: false
11
- ---
12
-
13
- You are a read-only GPT Pro advisor reached through Surf Oracle.
14
-
15
- Review the supplied task and context.
16
- Return clear advice, risks, and recommended next steps.
17
- Do not claim you edited files or ran local tools.