pi-subagents 0.48.0 → 0.50.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 (88) hide show
  1. package/CHANGELOG.md +82 -19
  2. package/agents/oracle.md +7 -5
  3. package/agents/researcher.md +1 -1
  4. package/agents/reviewer.md +2 -2
  5. package/agents/scout.md +1 -1
  6. package/agents/worker.md +1 -1
  7. package/docs/agents.md +3 -0
  8. package/docs/configuration.md +75 -8
  9. package/docs/extension-api.md +36 -0
  10. package/docs/missions.md +5 -3
  11. package/docs/observability.md +42 -2
  12. package/docs/tool-reference.md +19 -2
  13. package/docs/workflows.md +2 -2
  14. package/package.json +1 -1
  15. package/skills/pi-subagents/references/constraints-and-recipes.md +3 -2
  16. package/skills/pi-subagents/references/execution-controls.md +5 -4
  17. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  18. package/skills/pi-subagents/references/prompting-and-roles.md +31 -15
  19. package/src/agents/agent-serializer.ts +2 -0
  20. package/src/agents/agents.ts +37 -12
  21. package/src/api/external-runs.ts +174 -84
  22. package/src/api/preflight.ts +13 -7
  23. package/src/extension/config.ts +53 -0
  24. package/src/extension/index.ts +62 -9
  25. package/src/extension/public-execution.ts +32 -4
  26. package/src/extension/rpc.ts +5 -1
  27. package/src/extension/schemas.ts +10 -8
  28. package/src/extension/tool-description.ts +13 -9
  29. package/src/inspectors/herdr/actions.ts +13 -8
  30. package/src/inspectors/herdr/inspector-runner.ts +16 -3
  31. package/src/inspectors/herdr/project-panes.ts +2 -6
  32. package/src/inspectors/herdr/shell-command.ts +16 -0
  33. package/src/intercom/intercom-bridge.ts +5 -4
  34. package/src/intercom/native-supervisor-channel.ts +19 -42
  35. package/src/missions/goal-driver.ts +3 -1
  36. package/src/missions/store.ts +8 -3
  37. package/src/runs/background/active-async-capacity.ts +82 -25
  38. package/src/runs/background/active-run-index.ts +71 -1
  39. package/src/runs/background/async-execution.ts +73 -43
  40. package/src/runs/background/async-job-tracker.ts +5 -0
  41. package/src/runs/background/async-resume.ts +14 -6
  42. package/src/runs/background/async-status-snapshot.ts +277 -0
  43. package/src/runs/background/async-status.ts +8 -3
  44. package/src/runs/background/chain-root-attachment.ts +2 -2
  45. package/src/runs/background/completion-replay.ts +11 -1
  46. package/src/runs/background/fleet-view.ts +21 -6
  47. package/src/runs/background/result-files.ts +437 -0
  48. package/src/runs/background/result-watcher.ts +188 -41
  49. package/src/runs/background/resume-guidance.ts +27 -7
  50. package/src/runs/background/retained-children.ts +75 -18
  51. package/src/runs/background/run-id-resolver.ts +30 -24
  52. package/src/runs/background/run-status.ts +101 -4
  53. package/src/runs/background/scheduled-runs.ts +54 -28
  54. package/src/runs/background/stale-run-reconciler.ts +27 -13
  55. package/src/runs/background/subagent-runner.ts +298 -33
  56. package/src/runs/background/subagent-wait.ts +2 -0
  57. package/src/runs/background/wait-completions.ts +5 -2
  58. package/src/runs/foreground/async-dismiss-action.ts +2 -1
  59. package/src/runs/foreground/chain-execution.ts +16 -0
  60. package/src/runs/foreground/execution.ts +219 -15
  61. package/src/runs/foreground/prompt-audit.ts +4 -3
  62. package/src/runs/foreground/subagent-executor.ts +328 -43
  63. package/src/runs/shared/completion-guard.ts +107 -1
  64. package/src/runs/shared/external-cli-runner.ts +4 -0
  65. package/src/runs/shared/llm-intent-arbiter.ts +39 -23
  66. package/src/runs/shared/model-fallback.ts +16 -2
  67. package/src/runs/shared/nested-events.ts +66 -62
  68. package/src/runs/shared/orca-progress-tabs.ts +375 -0
  69. package/src/runs/shared/parallel-utils.ts +2 -0
  70. package/src/runs/shared/subagent-control.ts +15 -0
  71. package/src/runs/shared/subagent-prompt-runtime.ts +1 -9
  72. package/src/runs/shared/subagent-startup-retry.ts +12 -0
  73. package/src/runs/shared/tool-timeout.ts +93 -0
  74. package/src/shared/agent-stream-options.ts +5 -0
  75. package/src/shared/artifacts.ts +2 -6
  76. package/src/shared/display-text.ts +50 -0
  77. package/src/shared/node-executable.ts +21 -0
  78. package/src/shared/types.ts +51 -5
  79. package/src/slash/slash-commands.ts +34 -25
  80. package/src/slash/slash-live-state.ts +3 -0
  81. package/src/tui/fleet-status.ts +160 -45
  82. package/src/tui/fleet-transcript.ts +1 -48
  83. package/src/tui/fleet.ts +128 -16
  84. package/src/tui/render.ts +122 -44
  85. package/src/watchdog/permission-arbiter.ts +2 -1
  86. package/src/watchdog/review.ts +4 -3
  87. package/src/workflows/chat-progress.ts +10 -2
  88. package/src/workflows/scripted-workflow.ts +272 -76
@@ -1,129 +1,219 @@
1
- export const EXTERNAL_RUN_REGISTRY_VERSION = 1;
2
- export const EXTERNAL_RUN_REGISTRY_KEY = "pi-subagents.external-runs.v1";
1
+ import { sanitizeDisplayText, truncateDisplayText } from "../shared/display-text.ts";
3
2
 
4
- const MAX_PROVIDERS = 100;
5
- const MAX_RUNS_PER_PROVIDER = 10_000;
6
- const MAX_TEXT_LENGTH = 4_096;
3
+ export const EXTERNAL_RUN_REGISTRY_VERSION = 2;
4
+ export const EXTERNAL_RUN_REGISTRY_KEY = "pi-subagents.external-runs.v2";
7
5
 
8
- export type ExternalRunState = "running" | "completed" | "failed" | "stopped";
9
- export type ExternalRunCompletionReason = "exit" | "timeout" | "user-kill" | "auto-close-quiet" | "crash" | "unknown";
6
+ export const EXTERNAL_RUN_LIMITS = {
7
+ maxCachedRuns: 100,
8
+ maxSnapshotRuns: 20,
9
+ maxIdentityLength: 160,
10
+ /**
11
+ * A Pi session id is the session file path, which routinely exceeds a short
12
+ * identity budget in nested worktrees, so it is bounded like the other paths.
13
+ */
14
+ maxSessionIdLength: 4_096,
15
+ maxTextLength: 160,
16
+ maxPreviewLength: 4_096,
17
+ maxPathLength: 4_096,
18
+ maxSerializedBytes: 32 * 1_024,
19
+ } as const;
10
20
 
11
- /** An observational record for work owned by another runtime. */
21
+ export type ExternalRunState = "queued" | "running" | "completed" | "failed" | "stopped";
22
+
23
+ /** A display-only record for work owned by another extension or runtime. */
12
24
  export interface ExternalRun {
13
25
  id: string;
14
26
  sessionId: string;
15
27
  source: string;
28
+ label: string;
16
29
  state: ExternalRunState;
17
- command?: string;
18
- cwd?: string;
19
- isolationPath?: string;
20
- owner?: string;
21
- completionReason?: ExternalRunCompletionReason;
30
+ startedAt: number;
31
+ updatedAt?: number;
32
+ endedAt?: number;
33
+ currentAction?: string;
34
+ preview?: string;
22
35
  reportPath?: string;
23
- startedAt?: number;
24
- completedAt?: number;
25
- exitCode?: number | null;
26
- residualRisks?: string[];
36
+ transcriptPath?: string;
27
37
  }
28
38
 
29
- export interface ExternalRunProvider {
30
- name: string;
31
- listExternalRuns(): readonly ExternalRun[];
32
- }
39
+ export type ExternalRunUpdate = Partial<Omit<ExternalRun, "id" | "sessionId" | "source">>;
33
40
 
34
- export interface RegisteredExternalRun extends ExternalRun {
35
- provider: string;
41
+ export interface ExternalRunSnapshotOptions {
42
+ onMalformedRecord?: (message: string) => void;
43
+ ignoreMalformed?: boolean;
36
44
  }
37
45
 
38
46
  interface ExternalRunRegistry {
39
47
  version: typeof EXTERNAL_RUN_REGISTRY_VERSION;
40
- providers: Map<string, ExternalRunProvider>;
48
+ runs: Map<string, ExternalRun>;
41
49
  }
42
50
 
51
+ const RUN_FIELDS = new Set([
52
+ "id",
53
+ "sessionId",
54
+ "source",
55
+ "label",
56
+ "state",
57
+ "startedAt",
58
+ "updatedAt",
59
+ "endedAt",
60
+ "currentAction",
61
+ "preview",
62
+ "reportPath",
63
+ "transcriptPath",
64
+ ]);
65
+ const UPDATE_FIELDS = new Set([...RUN_FIELDS].filter((field) => field !== "id" && field !== "sessionId" && field !== "source"));
66
+
43
67
  function registry(): ExternalRunRegistry {
44
68
  const key = Symbol.for(EXTERNAL_RUN_REGISTRY_KEY);
45
69
  const target = globalThis as Record<PropertyKey, unknown>;
46
70
  const existing = target[key];
47
71
  if (existing === undefined) {
48
- const created: ExternalRunRegistry = { version: EXTERNAL_RUN_REGISTRY_VERSION, providers: new Map() };
72
+ const created: ExternalRunRegistry = { version: EXTERNAL_RUN_REGISTRY_VERSION, runs: new Map() };
49
73
  target[key] = created;
50
74
  return created;
51
75
  }
52
76
  if (!existing || typeof existing !== "object" || Array.isArray(existing)) throw new Error(`Malformed external-run registry at Symbol.for("${EXTERNAL_RUN_REGISTRY_KEY}").`);
53
77
  const candidate = existing as Partial<ExternalRunRegistry>;
54
- if (candidate.version !== EXTERNAL_RUN_REGISTRY_VERSION || !(candidate.providers instanceof Map)) throw new Error(`Unsupported external-run registry at Symbol.for("${EXTERNAL_RUN_REGISTRY_KEY}").`);
78
+ if (candidate.version !== EXTERNAL_RUN_REGISTRY_VERSION || !(candidate.runs instanceof Map)) throw new Error(`Unsupported external-run registry at Symbol.for("${EXTERNAL_RUN_REGISTRY_KEY}").`);
55
79
  return candidate as ExternalRunRegistry;
56
80
  }
57
81
 
58
- function text(value: unknown, field: string, required = false): string | undefined {
59
- if (value === undefined && !required) return undefined;
82
+ function inputObject(value: unknown, field: string, allowed: Set<string>): Record<string, unknown> {
83
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${field} must be an object.`);
84
+ const input = value as Record<string, unknown>;
85
+ const unknown = Object.keys(input).filter((key) => !allowed.has(key));
86
+ if (unknown.length) throw new Error(`${field} has unknown fields: ${unknown.join(", ")}.`);
87
+ return input;
88
+ }
89
+
90
+ function identity(value: unknown, field: string, maxLength: number = EXTERNAL_RUN_LIMITS.maxIdentityLength): string {
60
91
  if (typeof value !== "string" || value.length === 0 || value.trim() !== value || value.includes("\0")) throw new Error(`${field} must be a non-empty trimmed string without NUL characters.`);
61
- if (value.length > MAX_TEXT_LENGTH) throw new Error(`${field} must be at most ${MAX_TEXT_LENGTH} characters.`);
92
+ if (value.length > maxLength) throw new Error(`${field} must be at most ${maxLength} characters.`);
93
+ const safe = sanitizeDisplayText(value);
94
+ if (!safe || safe !== value) throw new Error(`${field} must contain only display-safe text.`);
62
95
  return value;
63
96
  }
64
97
 
65
- function timestamp(value: unknown, field: string): number | undefined {
66
- if (value === undefined) return undefined;
67
- if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error(`${field} must be a non-negative finite number.`);
98
+ function displayText(value: unknown, field: string, maxLength: number): string | undefined;
99
+ function displayText(value: unknown, field: string, maxLength: number, required: true): string;
100
+ function displayText(value: unknown, field: string, maxLength: number, required = false): string | undefined {
101
+ if (value === undefined && !required) return undefined;
102
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${field} must be a non-empty string.`);
103
+ const safe = sanitizeDisplayText(value.slice(0, Math.max(maxLength * 4, maxLength)));
104
+ if (!safe) throw new Error(`${field} must contain displayable text.`);
105
+ return truncateDisplayText(safe, maxLength);
106
+ }
107
+
108
+ function timestamp(value: unknown, field: string): number | undefined;
109
+ function timestamp(value: unknown, field: string, required: true): number;
110
+ function timestamp(value: unknown, field: string, required = false): number | undefined {
111
+ if (value === undefined && !required) return undefined;
112
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 8_640_000_000_000_000) throw new Error(`${field} must be a non-negative safe timestamp.`);
68
113
  return value;
69
114
  }
70
115
 
71
- function validateRun(value: unknown, provider: string, index: number): ExternalRun {
72
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`External-run provider '${provider}' item ${index} must be an object.`);
73
- const run = value as Record<string, unknown>;
74
- const fields = new Set(["id", "sessionId", "source", "state", "command", "cwd", "isolationPath", "owner", "completionReason", "reportPath", "startedAt", "completedAt", "exitCode", "residualRisks"]);
75
- const unknown = Object.keys(run).filter((key) => !fields.has(key));
76
- if (unknown.length) throw new Error(`External-run provider '${provider}' item ${index} has unknown fields: ${unknown.join(", ")}.`);
77
- if (!["running", "completed", "failed", "stopped"].includes(run.state as string)) throw new Error(`External-run provider '${provider}' item ${index} state is invalid.`);
78
- if (run.completionReason !== undefined && !["exit", "timeout", "user-kill", "auto-close-quiet", "crash", "unknown"].includes(run.completionReason as string)) throw new Error(`External-run provider '${provider}' item ${index} completionReason is invalid.`);
79
- if (run.exitCode !== undefined && run.exitCode !== null && (!Number.isInteger(run.exitCode) || !Number.isFinite(run.exitCode))) throw new Error(`External-run provider '${provider}' item ${index} exitCode must be an integer or null.`);
80
- if (run.residualRisks !== undefined && (!Array.isArray(run.residualRisks) || run.residualRisks.some((risk, riskIndex) => text(risk, `External-run provider '${provider}' item ${index} residualRisks[${riskIndex}]`, true) === undefined))) throw new Error(`External-run provider '${provider}' item ${index} residualRisks must be an array of strings.`);
116
+ function state(value: unknown, field: string): ExternalRunState {
117
+ if (value === "queued" || value === "running" || value === "completed" || value === "failed" || value === "stopped") return value;
118
+ throw new Error(`${field} is invalid.`);
119
+ }
120
+
121
+ function validateRun(value: unknown): ExternalRun {
122
+ const run = inputObject(value, "External run", RUN_FIELDS);
123
+ const updatedAt = timestamp(run.updatedAt, "External run updatedAt");
124
+ const endedAt = timestamp(run.endedAt, "External run endedAt");
125
+ const currentAction = displayText(run.currentAction, "External run currentAction", EXTERNAL_RUN_LIMITS.maxTextLength);
126
+ const preview = displayText(run.preview, "External run preview", EXTERNAL_RUN_LIMITS.maxPreviewLength);
127
+ const reportPath = displayText(run.reportPath, "External run reportPath", EXTERNAL_RUN_LIMITS.maxPathLength);
128
+ const transcriptPath = displayText(run.transcriptPath, "External run transcriptPath", EXTERNAL_RUN_LIMITS.maxPathLength);
81
129
  return {
82
- id: text(run.id, `External-run provider '${provider}' item ${index} id`, true)!,
83
- sessionId: text(run.sessionId, `External-run provider '${provider}' item ${index} sessionId`, true)!,
84
- source: text(run.source, `External-run provider '${provider}' item ${index} source`, true)!,
85
- state: run.state as ExternalRunState,
86
- ...(text(run.command, `External-run provider '${provider}' item ${index} command`) !== undefined ? { command: text(run.command, `External-run provider '${provider}' item ${index} command`) } : {}),
87
- ...(text(run.cwd, `External-run provider '${provider}' item ${index} cwd`) !== undefined ? { cwd: text(run.cwd, `External-run provider '${provider}' item ${index} cwd`) } : {}),
88
- ...(text(run.isolationPath, `External-run provider '${provider}' item ${index} isolationPath`) !== undefined ? { isolationPath: text(run.isolationPath, `External-run provider '${provider}' item ${index} isolationPath`) } : {}),
89
- ...(text(run.owner, `External-run provider '${provider}' item ${index} owner`) !== undefined ? { owner: text(run.owner, `External-run provider '${provider}' item ${index} owner`) } : {}),
90
- ...(run.completionReason !== undefined ? { completionReason: run.completionReason as ExternalRunCompletionReason } : {}),
91
- ...(text(run.reportPath, `External-run provider '${provider}' item ${index} reportPath`) !== undefined ? { reportPath: text(run.reportPath, `External-run provider '${provider}' item ${index} reportPath`) } : {}),
92
- ...(timestamp(run.startedAt, `External-run provider '${provider}' item ${index} startedAt`) !== undefined ? { startedAt: timestamp(run.startedAt, `External-run provider '${provider}' item ${index} startedAt`) } : {}),
93
- ...(timestamp(run.completedAt, `External-run provider '${provider}' item ${index} completedAt`) !== undefined ? { completedAt: timestamp(run.completedAt, `External-run provider '${provider}' item ${index} completedAt`) } : {}),
94
- ...(run.exitCode !== undefined ? { exitCode: run.exitCode as number | null } : {}),
95
- ...(run.residualRisks !== undefined ? { residualRisks: [...run.residualRisks] as string[] } : {}),
130
+ id: identity(run.id, "External run id"),
131
+ sessionId: identity(run.sessionId, "External run sessionId", EXTERNAL_RUN_LIMITS.maxSessionIdLength),
132
+ source: displayText(run.source, "External run source", EXTERNAL_RUN_LIMITS.maxTextLength, true),
133
+ label: displayText(run.label, "External run label", EXTERNAL_RUN_LIMITS.maxTextLength, true),
134
+ state: state(run.state, "External run state"),
135
+ startedAt: timestamp(run.startedAt, "External run startedAt", true),
136
+ ...(updatedAt !== undefined ? { updatedAt } : {}),
137
+ ...(endedAt !== undefined ? { endedAt } : {}),
138
+ ...(currentAction ? { currentAction } : {}),
139
+ ...(preview ? { preview } : {}),
140
+ ...(reportPath ? { reportPath } : {}),
141
+ ...(transcriptPath ? { transcriptPath } : {}),
96
142
  };
97
143
  }
98
144
 
99
- /** Register an observational provider. pi-subagents never starts, stops, or steers these processes. */
100
- export function registerExternalRunProvider(provider: ExternalRunProvider): () => void {
101
- if (!provider || typeof provider !== "object" || Array.isArray(provider) || Object.keys(provider).some((key) => key !== "name" && key !== "listExternalRuns")) throw new Error("External-run provider must contain only name and listExternalRuns.");
102
- const name = text(provider.name, "External-run provider name", true)!;
103
- if (typeof provider.listExternalRuns !== "function") throw new Error(`External-run provider '${name}' must expose listExternalRuns().`);
145
+ function key(sessionId: string, id: string): string {
146
+ return `${sessionId}\0${id}`;
147
+ }
148
+
149
+ function clone(run: ExternalRun): ExternalRun {
150
+ return { ...run };
151
+ }
152
+
153
+ /** Register one current-session external job. pi-subagents never controls the job. */
154
+ export function registerExternalRun(input: ExternalRun): ExternalRun {
155
+ const run = validateRun(input);
156
+ const current = registry();
157
+ const runKey = key(run.sessionId, run.id);
158
+ if (current.runs.has(runKey)) throw new Error(`External run '${run.id}' is already registered for session '${run.sessionId}'.`);
159
+ if (current.runs.size >= EXTERNAL_RUN_LIMITS.maxCachedRuns) throw new Error(`External-run registry supports at most ${EXTERNAL_RUN_LIMITS.maxCachedRuns} cached runs.`);
160
+ current.runs.set(runKey, run);
161
+ return clone(run);
162
+ }
163
+
164
+ /** Update display fields for a registered external job without changing its identity or owner. */
165
+ export function updateExternalRun(sessionId: string, id: string, update: ExternalRunUpdate): ExternalRun {
166
+ const safeSessionId = identity(sessionId, "External run sessionId", EXTERNAL_RUN_LIMITS.maxSessionIdLength);
167
+ const safeId = identity(id, "External run id");
168
+ const patch = inputObject(update, "External run update", UPDATE_FIELDS);
169
+ const current = registry();
170
+ const runKey = key(safeSessionId, safeId);
171
+ const previous = current.runs.get(runKey);
172
+ if (!previous) throw new Error(`External run '${safeId}' is not registered for session '${safeSessionId}'.`);
173
+ const next = validateRun({ ...previous, ...patch });
174
+ current.runs.set(runKey, next);
175
+ return clone(next);
176
+ }
177
+
178
+ /** Remove a cached external job. The caller remains responsible for its process and artifacts. */
179
+ export function unregisterExternalRun(sessionId: string, id: string): boolean {
180
+ return registry().runs.delete(key(identity(sessionId, "External run sessionId", EXTERNAL_RUN_LIMITS.maxSessionIdLength), identity(id, "External run id")));
181
+ }
182
+
183
+ function snapshotBytes(runs: readonly ExternalRun[]): number {
184
+ return Buffer.byteLength(JSON.stringify(runs), "utf8");
185
+ }
186
+
187
+ function getErrorMessage(error: unknown): string {
188
+ return error instanceof Error ? error.message : String(error);
189
+ }
190
+
191
+ /** Read a bounded cached snapshot for one Pi session. This never invokes third-party code. */
192
+ export function snapshotExternalRuns(sessionId: string, options: ExternalRunSnapshotOptions = {}): readonly ExternalRun[] {
193
+ const safeSessionId = identity(sessionId, "External-run snapshot sessionId", EXTERNAL_RUN_LIMITS.maxSessionIdLength);
104
194
  const current = registry();
105
- if (!current.providers.has(name) && current.providers.size >= MAX_PROVIDERS) throw new Error(`External-run registry supports at most ${MAX_PROVIDERS} providers.`);
106
- current.providers.set(name, provider);
107
- return () => { if (current.providers.get(name) === provider) current.providers.delete(name); };
108
- }
109
-
110
- /** Snapshot records for one Pi session. Records are read-only observations of foreign process ownership. */
111
- export function snapshotExternalRuns(sessionId: string): readonly RegisteredExternalRun[] {
112
- text(sessionId, "External-run snapshot sessionId", true);
113
- const records: RegisteredExternalRun[] = [];
114
- const identities = new Set<string>();
115
- for (const [name, provider] of registry().providers) {
116
- if (provider.name !== name) throw new Error(`External-run registry key '${name}' does not match provider name '${provider.name}'.`);
117
- const runs = provider.listExternalRuns();
118
- if (!Array.isArray(runs)) throw new Error(`External-run provider '${name}' listExternalRuns() must return an array.`);
119
- if (runs.length > MAX_RUNS_PER_PROVIDER) throw new Error(`External-run provider '${name}' returned more than ${MAX_RUNS_PER_PROVIDER} runs.`);
120
- runs.forEach((value, index) => {
121
- const run = validateRun(value, name, index);
122
- const identity = `${name}\0${run.sessionId}\0${run.id}`;
123
- if (identities.has(identity)) throw new Error(`External-run provider '${name}' returned duplicate run '${run.id}' for session '${run.sessionId}'.`);
124
- identities.add(identity);
125
- if (run.sessionId === sessionId) records.push({ provider: name, ...run });
126
- });
195
+ const runs: ExternalRun[] = [];
196
+ for (const [cacheKey, value] of current.runs.entries()) {
197
+ try {
198
+ const run = validateRun(value);
199
+ if (run.sessionId === safeSessionId) runs.push(run);
200
+ } catch (error) {
201
+ const message = `Malformed cached external run '${cacheKey}': ${getErrorMessage(error)}`;
202
+ if (!options.ignoreMalformed) throw new Error(message, { cause: error instanceof Error ? error : undefined });
203
+ current.runs.delete(cacheKey);
204
+ options.onMalformedRecord?.(message);
205
+ }
127
206
  }
128
- return records;
207
+ runs.sort((left, right) => {
208
+ const leftActive = left.state === "queued" || left.state === "running";
209
+ const rightActive = right.state === "queued" || right.state === "running";
210
+ if (leftActive !== rightActive) return leftActive ? -1 : 1;
211
+ return (right.updatedAt ?? right.endedAt ?? right.startedAt) - (left.updatedAt ?? left.endedAt ?? left.startedAt) || left.id.localeCompare(right.id);
212
+ });
213
+ const snapshot = runs.slice(0, EXTERNAL_RUN_LIMITS.maxSnapshotRuns).map(clone);
214
+ while (snapshot.length > 0 && snapshotBytes(snapshot) > EXTERNAL_RUN_LIMITS.maxSerializedBytes) snapshot.pop();
215
+ return snapshot;
129
216
  }
217
+
218
+ /** Alias for callers that prefer list terminology. */
219
+ export const listExternalRuns = snapshotExternalRuns;
@@ -20,6 +20,7 @@ import { resolveStepBehavior } from "../shared/settings.ts";
20
20
  import { agentDefinitionDigest, AGENT_DEFINITION_PROJECTION_VERSION, launchBindingDigest } from "../shared/launch-contract.ts";
21
21
  import { DIRS, TEMP_ROOT_DIR } from "../shared/types.ts";
22
22
  import { processTerminalCandidatePath, processTerminalPath } from "../runs/background/process-terminal.ts";
23
+ import { resultFilePath } from "../runs/background/result-files.ts";
23
24
  import { nestedResultsPath } from "../runs/shared/nested-events.ts";
24
25
 
25
26
  export const SUBAGENT_LAUNCH_CONTRACT_VERSION = 2 as const;
@@ -259,13 +260,18 @@ export async function resolveSubagentLaunchContract(input: SubagentLaunchContrac
259
260
  }
260
261
  if (resolvedSkills.missing.length > 0) diagnostics.push({ code: "missing_skill", severity: "error", message: `Missing skills: ${resolvedSkills.missing.join(", ")}` });
261
262
 
263
+ const externalRunner = agent.runner?.type === "external-cli";
262
264
  const availableModels = normalizeAvailableModels(input.availableModels);
263
265
  const preferredProvider = input.preferredProvider ?? input.parentModel?.provider;
264
- const primaryModel = resolveEffectiveSubagentModel(input.model, agent.model, input.parentModel, availableModels, preferredProvider, { scope: discovered.modelScope });
266
+ const primaryModel = externalRunner
267
+ ? undefined
268
+ : resolveEffectiveSubagentModel(input.model, agent.model, input.parentModel, availableModels, preferredProvider, { scope: discovered.modelScope });
265
269
  const effectiveThinkingConfig = input.thinking !== undefined ? input.thinking : agent.thinking;
266
- const model = applyThinkingSuffix(primaryModel, effectiveThinkingConfig, input.thinking !== undefined);
267
- const modelCandidates = buildModelCandidates(primaryModel, agent.fallbackModels, availableModels, preferredProvider, { scope: discovered.modelScope })
268
- .map((candidate) => applyThinkingSuffix(candidate, effectiveThinkingConfig, input.thinking !== undefined) ?? candidate);
270
+ const model = externalRunner ? undefined : applyThinkingSuffix(primaryModel, effectiveThinkingConfig, input.thinking !== undefined);
271
+ const modelCandidates = externalRunner
272
+ ? []
273
+ : buildModelCandidates(primaryModel, agent.fallbackModels, availableModels, preferredProvider, { scope: discovered.modelScope })
274
+ .map((candidate) => applyThinkingSuffix(candidate, effectiveThinkingConfig, input.thinking !== undefined) ?? candidate);
269
275
  let toolPlan: PiLaunchToolPlan;
270
276
  try {
271
277
  toolPlan = resolvePiLaunchToolPlan({
@@ -285,7 +291,7 @@ export async function resolveSubagentLaunchContract(input: SubagentLaunchContrac
285
291
  return { ok: false, code: "denied_required_tool", message, diagnostics };
286
292
  }
287
293
  const artifactsEnabled = input.artifacts !== false;
288
- const artifactsDir = artifactsEnabled ? getArtifactsDir(input.parentSessionFile ?? null, effectiveCwd, input.artifactDir ?? "project") : undefined;
294
+ const artifactsDir = artifactsEnabled ? getArtifactsDir(input.parentSessionFile ?? null, effectiveCwd, input.artifactDir) : undefined;
289
295
  const artifactPaths = artifactsDir ? getArtifactPaths(artifactsDir, runId, agent.name, 0) : undefined;
290
296
  const outputPath = resolveSingleOutputPath(behavior.output, effectiveCwd, effectiveCwd, artifactsDir ? path.join(artifactsDir, "outputs", runId) : undefined);
291
297
  const sessionRoot = input.sessionDir ? path.resolve(input.sessionDir) : input.sessionRoot ? path.join(path.resolve(input.sessionRoot), runId) : undefined;
@@ -295,9 +301,9 @@ export async function resolveSubagentLaunchContract(input: SubagentLaunchContrac
295
301
  : path.join(DIRS.async, runId);
296
302
  const lifecycleResultPath = input.nestedRootRunId
297
303
  ? nestedResultsPath(input.nestedRootRunId, runId)
298
- : path.join(DIRS.results, `${runId}.json`);
304
+ : resultFilePath(DIRS.results, runId);
299
305
  if (!sessionDir) diagnostics.push({ code: "host_required", severity: "host-required", message: "No sessionRoot/sessionDir was supplied; exact child session paths require the Pi host session-root policy." });
300
- if (input.availableModels === undefined && (input.model || agent.model || input.parentModel)) {
306
+ if (!externalRunner && input.availableModels === undefined && (input.model || agent.model || input.parentModel)) {
301
307
  diagnostics.push({ code: "host_required", severity: "host-required", message: "No availableModels snapshot was supplied; model resolution may differ from the active Pi host registry." });
302
308
  }
303
309
  if (resolvedSkills.missing.length > 0) {
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { Key } from "@earendil-works/pi-tui";
4
5
  import { FLEET_KEYBINDING_ACTIONS, type ArtifactDirPreference, type ExtensionConfig } from "../shared/types.ts";
5
6
  import { validateMissionStoreConfig } from "../missions/store.ts";
6
7
  import { validateAuthorityPolicy } from "../policy/authority.ts";
@@ -9,6 +10,21 @@ import { validatePermissionConfig } from "../runs/shared/permissions.ts";
9
10
 
10
11
  const ARTIFACT_DIR_PREFERENCES = new Set<ArtifactDirPreference>(["project", "session", "temp"]);
11
12
  const FLEET_KEYBINDING_ACTION_SET = new Set<string>(FLEET_KEYBINDING_ACTIONS);
13
+ const KEY_MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
14
+ const BASE_KEY_IDS = new Set([
15
+ ..."abcdefghijklmnopqrstuvwxyz0123456789",
16
+ ...Object.values(Key).flatMap((value) => typeof value === "string" ? [value.toLowerCase()] : []),
17
+ ]);
18
+
19
+ function isValidKeyId(value: string): boolean {
20
+ if (value !== value.trim()) return false;
21
+ const parts = value.toLowerCase().split("+");
22
+ const base = parts.pop();
23
+ if (!base || !BASE_KEY_IDS.has(base)) return false;
24
+ return parts.length <= KEY_MODIFIERS.size
25
+ && new Set(parts).size === parts.length
26
+ && parts.every((modifier) => KEY_MODIFIERS.has(modifier));
27
+ }
12
28
 
13
29
  export function resolveScheduledStoreRoot(value: string): string {
14
30
  const expanded = value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
@@ -46,7 +62,42 @@ function validateArtifactConfig(value: unknown): void {
46
62
  }
47
63
  }
48
64
 
65
+ function validateOrcaProgressTabsConfig(value: unknown): void {
66
+ if (value === undefined) return;
67
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("config.orcaProgressTabs must be a JSON object");
68
+ const config = value as Record<string, unknown>;
69
+ for (const key of Object.keys(config)) {
70
+ if (key !== "enabled") throw new Error(`config.orcaProgressTabs.${key} is not supported`);
71
+ }
72
+ if (config.enabled !== undefined && typeof config.enabled !== "boolean") {
73
+ throw new Error("config.orcaProgressTabs.enabled must be a boolean");
74
+ }
75
+ }
76
+
77
+ function validateMainWindowRendererConfig(value: unknown): void {
78
+ if (value === undefined) return;
79
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("config.mainWindowRenderer must be a JSON object");
80
+ const rendererConfig = value as Record<string, unknown>;
81
+ if (rendererConfig.horizontalSpacing !== undefined
82
+ && (typeof rendererConfig.horizontalSpacing !== "number"
83
+ || !Number.isInteger(rendererConfig.horizontalSpacing)
84
+ || rendererConfig.horizontalSpacing < 0
85
+ || rendererConfig.horizontalSpacing > 4)) {
86
+ throw new Error("config.mainWindowRenderer.horizontalSpacing must be an integer from 0 to 4");
87
+ }
88
+ if (rendererConfig.compactResultMaxLines !== undefined
89
+ && (typeof rendererConfig.compactResultMaxLines !== "number"
90
+ || !Number.isInteger(rendererConfig.compactResultMaxLines)
91
+ || rendererConfig.compactResultMaxLines < 1)) {
92
+ throw new Error("config.mainWindowRenderer.compactResultMaxLines must be a positive integer");
93
+ }
94
+ }
95
+
49
96
  function validateConfig(config: Record<string, unknown>): void {
97
+ if (config.foregroundDetachShortcut !== undefined
98
+ && (typeof config.foregroundDetachShortcut !== "string" || !isValidKeyId(config.foregroundDetachShortcut))) {
99
+ throw new Error("config.foregroundDetachShortcut must be a valid keybinding string such as \"ctrl+b\"");
100
+ }
50
101
  if (config.artifactDir !== undefined && !ARTIFACT_DIR_PREFERENCES.has(config.artifactDir as ArtifactDirPreference)) {
51
102
  throw new Error(`config.artifactDir must be "project", "session", or "temp"`);
52
103
  }
@@ -65,6 +116,8 @@ function validateConfig(config: Record<string, unknown>): void {
65
116
  validateScheduledRunsConfig(config.scheduledRuns);
66
117
  validateFleetKeybindingsConfig(config.fleetKeybindings);
67
118
  validateArtifactConfig(config.artifactConfig);
119
+ validateMainWindowRendererConfig(config.mainWindowRenderer);
120
+ validateOrcaProgressTabsConfig(config.orcaProgressTabs);
68
121
  }
69
122
 
70
123
  export function getConfigPath(): string {