@pi-unipi/subagents 2.6.1 → 2.9.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 (107) hide show
  1. package/README.md +89 -78
  2. package/agents/delegate.md +14 -0
  3. package/agents/oracle.md +78 -0
  4. package/agents/researcher.md +52 -0
  5. package/agents/reviewer.md +79 -0
  6. package/agents/scout.md +50 -0
  7. package/agents/worker.md +59 -0
  8. package/package.json +22 -20
  9. package/prompts/council.md +48 -0
  10. package/prompts/gather-context-and-clarify.md +13 -0
  11. package/prompts/parallel-cleanup.md +59 -0
  12. package/prompts/parallel-research.md +50 -0
  13. package/prompts/parallel-review.md +54 -0
  14. package/prompts/review-loop.md +43 -0
  15. package/skills/council-mode/SKILL.md +230 -0
  16. package/skills/subagents/SKILL.md +49 -0
  17. package/skills/subagents/references/constraints-and-recipes.md +259 -0
  18. package/skills/subagents/references/execution-controls.md +454 -0
  19. package/skills/subagents/references/management-authoring-rpc.md +161 -0
  20. package/skills/subagents/references/multi-lane-orchestration.md +39 -0
  21. package/skills/subagents/references/prompting-and-roles.md +267 -0
  22. package/src/acceptance.ts +331 -0
  23. package/src/agent-manager.ts +405 -0
  24. package/src/agent-memory.ts +300 -0
  25. package/src/agent-overrides.ts +183 -0
  26. package/src/agent-runner.ts +271 -0
  27. package/src/async-runner.ts +417 -0
  28. package/src/authority-policy.ts +47 -0
  29. package/src/budgets.ts +285 -0
  30. package/src/child-safety.ts +140 -0
  31. package/src/config.ts +226 -0
  32. package/src/conversation-viewer.ts +281 -0
  33. package/src/core-compat.ts +94 -0
  34. package/src/custom-agents.ts +323 -0
  35. package/src/file-system-retry.ts +50 -0
  36. package/src/fleet-data.ts +61 -0
  37. package/src/fleet-view.ts +316 -0
  38. package/src/foreground-detach.ts +59 -0
  39. package/src/fork-context.ts +285 -0
  40. package/src/global.d.ts +7 -0
  41. package/src/guide.ts +129 -0
  42. package/src/index.ts +1149 -0
  43. package/src/mission-state.ts +133 -0
  44. package/src/mission-store.ts +434 -0
  45. package/src/model-resolver.ts +79 -0
  46. package/src/output-limits.ts +142 -0
  47. package/src/parity-types.ts +315 -0
  48. package/src/pi-args.ts +161 -0
  49. package/src/pi-spawn.ts +147 -0
  50. package/src/result-files.ts +232 -0
  51. package/src/result-watcher.ts +221 -0
  52. package/src/retained-children.ts +176 -0
  53. package/src/run-fanout-budget.ts +356 -0
  54. package/src/scheduled-runs.ts +351 -0
  55. package/src/schemas.ts +263 -0
  56. package/src/slash-commands.ts +77 -0
  57. package/src/supervisor-channel.ts +249 -0
  58. package/src/tool-handler.ts +1144 -0
  59. package/src/types.ts +238 -0
  60. package/src/widget.ts +447 -0
  61. package/src/workflow-script.ts +737 -0
  62. package/src/workflow-worker.ts +384 -0
  63. package/src/worktree.ts +614 -0
  64. package/dist/agent-manager.d.ts +0 -81
  65. package/dist/agent-manager.d.ts.map +0 -1
  66. package/dist/agent-manager.js +0 -295
  67. package/dist/agent-manager.js.map +0 -1
  68. package/dist/agent-runner.d.ts +0 -51
  69. package/dist/agent-runner.d.ts.map +0 -1
  70. package/dist/agent-runner.js +0 -262
  71. package/dist/agent-runner.js.map +0 -1
  72. package/dist/config.d.ts +0 -24
  73. package/dist/config.d.ts.map +0 -1
  74. package/dist/config.js +0 -132
  75. package/dist/config.js.map +0 -1
  76. package/dist/conversation-viewer.d.ts +0 -40
  77. package/dist/conversation-viewer.d.ts.map +0 -1
  78. package/dist/conversation-viewer.js +0 -276
  79. package/dist/conversation-viewer.js.map +0 -1
  80. package/dist/core-compat.d.ts +0 -21
  81. package/dist/core-compat.d.ts.map +0 -1
  82. package/dist/core-compat.js +0 -86
  83. package/dist/core-compat.js.map +0 -1
  84. package/dist/custom-agents.d.ts +0 -14
  85. package/dist/custom-agents.d.ts.map +0 -1
  86. package/dist/custom-agents.js +0 -113
  87. package/dist/custom-agents.js.map +0 -1
  88. package/dist/file-lock.d.ts +0 -42
  89. package/dist/file-lock.d.ts.map +0 -1
  90. package/dist/file-lock.js +0 -91
  91. package/dist/file-lock.js.map +0 -1
  92. package/dist/index.d.ts +0 -10
  93. package/dist/index.d.ts.map +0 -1
  94. package/dist/index.js +0 -758
  95. package/dist/index.js.map +0 -1
  96. package/dist/model-resolver.d.ts +0 -19
  97. package/dist/model-resolver.d.ts.map +0 -1
  98. package/dist/model-resolver.js +0 -61
  99. package/dist/model-resolver.js.map +0 -1
  100. package/dist/types.d.ts +0 -98
  101. package/dist/types.d.ts.map +0 -1
  102. package/dist/types.js +0 -47
  103. package/dist/types.js.map +0 -1
  104. package/dist/widget.d.ts +0 -56
  105. package/dist/widget.d.ts.map +0 -1
  106. package/dist/widget.js +0 -396
  107. package/dist/widget.js.map +0 -1
package/src/budgets.ts ADDED
@@ -0,0 +1,285 @@
1
+ /**
2
+ * @pi-unipi/subagents — Turn / tool / usage budgets
3
+ *
4
+ * Ported from pi-subagents src/runs/shared/{turn-budget,tool-budget,
5
+ * usage-budget}.ts. Semantics identical; env override uses OUR prefix
6
+ * (UNIPI_SUBAGENT_TOOL_BUDGET). In-process foreground children enforce the
7
+ * turn budget at assistant-turn boundaries and the tool budget by blocking
8
+ * tools past the hard cap.
9
+ */
10
+
11
+ import type {
12
+ ResolvedTurnBudget,
13
+ ResolvedToolBudget,
14
+ ToolBudgetConfig,
15
+ TurnBudgetState,
16
+ ToolBudgetState,
17
+ UsageBudgetConfig,
18
+ } from "./parity-types.js";
19
+
20
+ // ============================================================================
21
+ // Turn budget
22
+ // ============================================================================
23
+
24
+ export const DEFAULT_TURN_BUDGET_GRACE_TURNS = 1;
25
+
26
+ export function resolveTurnBudgetConfig(
27
+ raw: unknown,
28
+ label = "turnBudget",
29
+ ): { turnBudget?: ResolvedTurnBudget; error?: string } {
30
+ if (raw === undefined) return {};
31
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
32
+ return { error: `${label} must be an object with maxTurns and optional graceTurns.` };
33
+ }
34
+ const unknownField = Object.keys(raw as Record<string, unknown>).find(
35
+ (key) => key !== "maxTurns" && key !== "graceTurns",
36
+ );
37
+ if (unknownField) return { error: `${label}.${unknownField} is not supported.` };
38
+ const budget = raw as { maxTurns?: unknown; graceTurns?: unknown };
39
+ if (typeof budget.maxTurns !== "number" || !Number.isInteger(budget.maxTurns) || budget.maxTurns < 1) {
40
+ return { error: `${label}.maxTurns must be an integer >= 1.` };
41
+ }
42
+ const graceTurns = budget.graceTurns ?? DEFAULT_TURN_BUDGET_GRACE_TURNS;
43
+ if (typeof graceTurns !== "number" || !Number.isInteger(graceTurns) || graceTurns < 0) {
44
+ return { error: `${label}.graceTurns must be an integer >= 0.` };
45
+ }
46
+ return { turnBudget: { maxTurns: budget.maxTurns, graceTurns } };
47
+ }
48
+
49
+ export function appendTurnBudgetSystemPrompt(
50
+ systemPrompt: string,
51
+ budget: { maxTurns: number; graceTurns?: number } | undefined,
52
+ ): string {
53
+ if (!budget) return systemPrompt;
54
+ const graceTurns = budget.graceTurns ?? DEFAULT_TURN_BUDGET_GRACE_TURNS;
55
+ const grace = graceTurns === 1 ? "1 additional assistant turn" : `${graceTurns} additional assistant turns`;
56
+ const block = [
57
+ "## Turn budget",
58
+ `This child run has a soft budget of ${budget.maxTurns} assistant turn${budget.maxTurns === 1 ? "" : "s"}.`,
59
+ `After that, ${grace} may be allowed only for a final wrap-up.`,
60
+ "When you approach or reach the soft budget, stop starting new tool work and return the final answer immediately.",
61
+ "If you continue past the soft budget plus grace turns, the supervisor may abort the run and return only partial output.",
62
+ ].join("\n");
63
+ return systemPrompt.trim() ? `${systemPrompt.trim()}\n\n${block}` : block;
64
+ }
65
+
66
+ export function turnBudgetSoftNote(budget: ResolvedTurnBudget, turnCount: number): string {
67
+ return `Turn budget wrap-up was requested after ${turnCount} assistant turn${turnCount === 1 ? "" : "s"} (soft limit ${budget.maxTurns}, grace ${budget.graceTurns}). Output may be partial.`;
68
+ }
69
+
70
+ export function turnBudgetExceededMessage(budget: ResolvedTurnBudget, turnCount: number): string {
71
+ return `Subagent exceeded turn budget after ${turnCount} assistant turn${turnCount === 1 ? "" : "s"} (soft limit ${budget.maxTurns} + grace ${budget.graceTurns}).`;
72
+ }
73
+
74
+ export function turnBudgetDeferredNote(budget: ResolvedTurnBudget, turnCount: number): string {
75
+ return `Turn-budget termination was deferred at ${turnCount} assistant turn${turnCount === 1 ? "" : "s"} (soft limit ${budget.maxTurns} + grace ${budget.graceTurns}) because the assistant started tool work. The run ended before another safe assistant boundary; output may be partial.`;
76
+ }
77
+
78
+ export function formatTurnBudgetOutput(message: string, output: string): string {
79
+ return output.trim()
80
+ ? `${message}\n\nPartial output before turn-budget abort:\n${output}`
81
+ : message;
82
+ }
83
+
84
+ export function turnBudgetDecision(
85
+ budget: ResolvedTurnBudget,
86
+ turnCount: number,
87
+ terminalAssistantStop: boolean,
88
+ toolWorkActiveOrStarting: boolean,
89
+ enforceHardLimit = false,
90
+ ): "continue" | "defer" | "abort" {
91
+ const hardLimit = budget.maxTurns + budget.graceTurns;
92
+ if (terminalAssistantStop || turnCount < hardLimit) return "continue";
93
+ if (toolWorkActiveOrStarting && !enforceHardLimit) return "defer";
94
+ return "abort";
95
+ }
96
+
97
+ export function turnBudgetState(
98
+ budget: ResolvedTurnBudget,
99
+ turnCount: number,
100
+ exceeded: boolean,
101
+ ): TurnBudgetState {
102
+ return {
103
+ ...budget,
104
+ turns: turnCount,
105
+ outcome: exceeded ? "exceeded" : "wrap-up-requested",
106
+ };
107
+ }
108
+
109
+ // ============================================================================
110
+ // Tool budget
111
+ // ============================================================================
112
+
113
+ export const DEFAULT_TOOL_BUDGET_BLOCK = ["read", "grep", "find", "ls"] as const;
114
+ export const TOOL_BUDGET_ENV = "UNIPI_SUBAGENT_TOOL_BUDGET";
115
+
116
+ export function normalizeToolBudgetBlock(block: ToolBudgetConfig["block"] | undefined): "*" | string[] {
117
+ if (block === "*") return "*";
118
+ if (block === undefined) return [...DEFAULT_TOOL_BUDGET_BLOCK];
119
+ return [...new Set(block.map((tool) => tool.trim()).filter(Boolean))];
120
+ }
121
+
122
+ export function validateToolBudgetConfig(
123
+ raw: unknown,
124
+ label = "toolBudget",
125
+ options: { minimumHard?: 0 | 1 } = {},
126
+ ): { budget?: ResolvedToolBudget; error?: string } {
127
+ if (raw === undefined) return {};
128
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
129
+ return { error: `${label} must be an object with hard and optional soft/block.` };
130
+ }
131
+ const value = raw as ToolBudgetConfig;
132
+ const minimumHard = options.minimumHard ?? 1;
133
+ if (typeof value.hard !== "number" || !Number.isInteger(value.hard) || value.hard < minimumHard) {
134
+ return { error: `${label}.hard must be an integer >= ${minimumHard}.` };
135
+ }
136
+ if (value.soft !== undefined && (typeof value.soft !== "number" || !Number.isInteger(value.soft) || value.soft < 1)) {
137
+ return { error: `${label}.soft must be an integer >= 1 when provided.` };
138
+ }
139
+ if (value.soft !== undefined && value.soft > value.hard) {
140
+ return { error: `${label}.soft must be <= ${label}.hard.` };
141
+ }
142
+ if (value.block !== undefined && value.block !== "*") {
143
+ if (!Array.isArray(value.block)) return { error: `${label}.block must be "*" or an array of tool names.` };
144
+ if (value.block.length === 0) return { error: `${label}.block must contain at least one tool name.` };
145
+ for (const item of value.block) {
146
+ if (typeof item !== "string" || !item.trim()) return { error: `${label}.block must contain non-empty tool names.` };
147
+ }
148
+ }
149
+ return {
150
+ budget: {
151
+ hard: value.hard,
152
+ ...(value.soft !== undefined ? { soft: value.soft } : {}),
153
+ block: normalizeToolBudgetBlock(value.block),
154
+ },
155
+ };
156
+ }
157
+
158
+ export function shouldBlockToolForBudget(
159
+ budget: ResolvedToolBudget,
160
+ toolName: string,
161
+ nextToolCount: number,
162
+ ): boolean {
163
+ if (nextToolCount <= budget.hard) return false;
164
+ return budget.block === "*" || budget.block.includes(toolName);
165
+ }
166
+
167
+ export function toolBudgetSoftNudge(budget: ResolvedToolBudget, toolCount: number): string {
168
+ return `Tool budget soft limit reached after ${toolCount} tool call${toolCount === 1 ? "" : "s"} (soft ${budget.soft}, hard ${budget.hard}). Stop starting new browsing/search work and finalize from the context you already have.`;
169
+ }
170
+
171
+ export function toolBudgetBlockedMessage(budget: ResolvedToolBudget, toolName: string, toolCount: number): string {
172
+ return `Tool budget hard limit reached after ${toolCount} tool call${toolCount === 1 ? "" : "s"} (hard ${budget.hard}). The '${toolName}' tool is blocked so you can finalize from the context you already have.`;
173
+ }
174
+
175
+ export function toolBudgetState(
176
+ budget: ResolvedToolBudget,
177
+ toolCount: number,
178
+ blockedTool?: string,
179
+ ): ToolBudgetState {
180
+ const overHard = toolCount > budget.hard;
181
+ const overSoft = budget.soft !== undefined && toolCount >= budget.soft;
182
+ return {
183
+ ...budget,
184
+ toolCalls: toolCount,
185
+ outcome: overHard ? "hard-blocked" : overSoft ? "soft-reached" : "within-budget",
186
+ };
187
+ }
188
+
189
+ // ============================================================================
190
+ // Usage budget
191
+ // ============================================================================
192
+
193
+ export interface UsageBudgetLimitConfig {
194
+ soft?: number;
195
+ hard: number;
196
+ }
197
+
198
+ export interface UsageBudgetState {
199
+ version: 1;
200
+ source: "reported";
201
+ tokens?: UsageBudgetLimitConfig & { used: number; outcome: "within-budget" | "soft-exceeded" | "hard-exceeded" };
202
+ costUsd?: UsageBudgetLimitConfig & { used: number; outcome: "within-budget" | "soft-exceeded" | "hard-exceeded" };
203
+ exhausted: boolean;
204
+ reason?: "tokens" | "costUsd";
205
+ }
206
+
207
+ function validateLimit(raw: unknown, label: string): { limit?: UsageBudgetLimitConfig; error?: string } {
208
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { error: `${label} must be an object with hard and optional soft.` };
209
+ const value = raw as Record<string, unknown>;
210
+ const unknown = Object.keys(value).find((key) => key !== "soft" && key !== "hard");
211
+ if (unknown) return { error: `${label}.${unknown} is not supported.` };
212
+ if (typeof value.hard !== "number" || !Number.isFinite(value.hard) || value.hard <= 0) {
213
+ return { error: `${label}.hard must be a positive number.` };
214
+ }
215
+ if (value.soft !== undefined && (typeof value.soft !== "number" || !Number.isFinite(value.soft) || value.soft <= 0)) {
216
+ return { error: `${label}.soft must be a positive number when provided.` };
217
+ }
218
+ if (value.soft !== undefined && value.soft > value.hard) {
219
+ return { error: `${label}.soft must be <= ${label}.hard.` };
220
+ }
221
+ return { limit: { hard: value.hard, ...(value.soft !== undefined ? { soft: value.soft } : {}) } };
222
+ }
223
+
224
+ export function validateUsageBudgetConfig(value: unknown, label = "usageBudget"): { budget?: UsageBudgetConfig; error?: string } {
225
+ if (value === undefined) return {};
226
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { error: `${label} must be an object.` };
227
+ const raw = value as Record<string, unknown>;
228
+ const unknown = Object.keys(raw).find((key) => key !== "tokens" && key !== "costUsd");
229
+ if (unknown) return { error: `${label}.${unknown} is not supported.` };
230
+ const budget: UsageBudgetConfig = {};
231
+ if (raw.tokens !== undefined) {
232
+ const tokens = validateLimit(raw.tokens, `${label}.tokens`);
233
+ if (tokens.error) return { error: tokens.error };
234
+ if (tokens.limit) budget.tokens = tokens.limit;
235
+ }
236
+ if (raw.costUsd !== undefined) {
237
+ const costUsd = validateLimit(raw.costUsd, `${label}.costUsd`);
238
+ if (costUsd.error) return { error: costUsd.error };
239
+ if (costUsd.limit) budget.costUsd = costUsd.limit;
240
+ }
241
+ if (!budget.tokens && !budget.costUsd) return { error: `${label} must include tokens or costUsd.` };
242
+ return { budget };
243
+ }
244
+
245
+ function metricState(
246
+ limit: UsageBudgetLimitConfig | undefined,
247
+ used: number,
248
+ ): UsageBudgetState["tokens"] {
249
+ if (!limit) return undefined;
250
+ return {
251
+ ...limit,
252
+ used,
253
+ outcome: used >= limit.hard ? "hard-exceeded" : limit.soft !== undefined && used >= limit.soft ? "soft-exceeded" : "within-budget",
254
+ };
255
+ }
256
+
257
+ export function usageBudgetState(
258
+ config: UsageBudgetConfig | undefined,
259
+ totals: { inputTokens?: number; outputTokens?: number; costUsd?: number } | undefined,
260
+ ): UsageBudgetState | undefined {
261
+ if (!config) return undefined;
262
+ const inputTokens = totals?.inputTokens ?? 0;
263
+ const outputTokens = totals?.outputTokens ?? 0;
264
+ const tokens = metricState(config.tokens, inputTokens + outputTokens);
265
+ const costUsd = metricState(config.costUsd, totals?.costUsd ?? 0);
266
+ const reason = tokens?.outcome === "hard-exceeded" ? "tokens" : costUsd?.outcome === "hard-exceeded" ? "costUsd" : undefined;
267
+ return {
268
+ version: 1,
269
+ source: "reported",
270
+ ...(tokens ? { tokens } : {}),
271
+ ...(costUsd ? { costUsd } : {}),
272
+ exhausted: reason !== undefined,
273
+ ...(reason ? { reason } : {}),
274
+ };
275
+ }
276
+
277
+ export function usageBudgetExceededMessage(state: UsageBudgetState): string {
278
+ if (state.reason === "tokens" && state.tokens) {
279
+ return `Usage budget exhausted: reported tokens ${state.tokens.used} reached hard limit ${state.tokens.hard}.`;
280
+ }
281
+ if (state.reason === "costUsd" && state.costUsd) {
282
+ return `Usage budget exhausted: reported cost $${state.costUsd.used.toFixed(6)} reached hard limit $${state.costUsd.hard.toFixed(6)}.`;
283
+ }
284
+ return "Usage budget exhausted.";
285
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * @pi-unipi/subagents — Child safety, depth guard, context resolution
3
+ *
4
+ * Ported from pi-subagents subagent-prompt-runtime.ts boundary instructions
5
+ * and context-mode.ts. Tool references adapted to OUR names (spawn_helper).
6
+ * In-process foreground children are always "fresh" context; explicit fork
7
+ * resolves here to a visible error pointing at the async path (Phase 3 adds
8
+ * the process-based fork runner).
9
+ */
10
+
11
+ import type { AgentConfig, SubagentsConfig } from "./types.js";
12
+
13
+ export type ContextMode = "fresh" | "fork";
14
+
15
+ export function isContextMode(value: unknown): value is ContextMode {
16
+ return value === "fresh" || value === "fork";
17
+ }
18
+
19
+ export const CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS = [
20
+ "You are a child subagent, not the parent orchestrator.",
21
+ "The parent session owns delegation, orchestration, review fanout, and follow-up worker launches.",
22
+ "Ignore prior parent-only orchestration instructions in inherited conversation history.",
23
+ "Do not propose or run subagents. Complete only your assigned role-specific task with the tools available to you.",
24
+ "If you need to edit files, use the available editing tools. Do not print tool-call syntax, patches, or pseudo-tool calls as text.",
25
+ ].join("\n");
26
+
27
+ export const CHILD_FANOUT_BOUNDARY_INSTRUCTIONS = [
28
+ "You are a child subagent with explicit fanout responsibility for this assigned task.",
29
+ "The parent session owns final orchestration, acceptance, and follow-up implementation launches.",
30
+ "You may use the `spawn_helper` tool only for the fanout work explicitly requested in this task.",
31
+ "Do not broaden yourself into general parent orchestration. Do not launch follow-up workers unless the task explicitly asks for that.",
32
+ "The maxSubagentDepth cap still applies and may block further fanout.",
33
+ "If you need to edit files, use the available editing tools. Do not print tool-call syntax, patches, or pseudo-tool calls as text.",
34
+ ].join("\n");
35
+
36
+ /** Whether the resolved agent's tools opt the child into fanout (spawn_helper). */
37
+ export function isFanoutChild(agent: AgentConfig | undefined): boolean {
38
+ return !!agent?.builtinToolNames?.includes("spawn_helper");
39
+ }
40
+
41
+ /** Prepend boundary instructions to a child prompt (fanout children get the fanout variant). */
42
+ export function withChildBoundaryInstructions(prompt: string, agent: AgentConfig | undefined): string {
43
+ const boundary = isFanoutChild(agent) ? CHILD_FANOUT_BOUNDARY_INSTRUCTIONS : CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS;
44
+ const text = prompt.trim();
45
+ return text ? `${boundary}\n\n${text}` : boundary;
46
+ }
47
+
48
+ // ============================================================================
49
+ // Depth guard
50
+ // ============================================================================
51
+
52
+ export const MAX_SUBAGENT_DEPTH_ENV = "UNIPI_SUBAGENT_MAX_DEPTH";
53
+ export const CURRENT_SUBAGENT_DEPTH_ENV = "UNIPI_SUBAGENT_DEPTH";
54
+
55
+ /**
56
+ * Resolve the effective depth cap. An inherited stricter cap cannot be
57
+ * relaxed; per-agent caps can only tighten. Reference semantics.
58
+ */
59
+ export function resolveMaxSubagentDepth(
60
+ agent: AgentConfig | undefined,
61
+ config: SubagentsConfig | undefined,
62
+ env: NodeJS.ProcessEnv = process.env,
63
+ ): number {
64
+ const inheritedRaw = env[MAX_SUBAGENT_DEPTH_ENV];
65
+ const inherited = inheritedRaw !== undefined && inheritedRaw.trim() !== "" ? Number(inheritedRaw) : undefined;
66
+ if (inherited !== undefined && Number.isInteger(inherited) && inherited >= 0) {
67
+ const agentCap = agent?.maxSubagentDepth;
68
+ if (agentCap !== undefined && Number.isInteger(agentCap) && agentCap >= 0 && agentCap < inherited) {
69
+ return agentCap;
70
+ }
71
+ return inherited;
72
+ }
73
+ const configured = config?.maxSubagentDepth;
74
+ if (configured !== undefined && Number.isInteger(configured) && configured > 0) return configured;
75
+ return 2; // reference default
76
+ }
77
+
78
+ /**
79
+ * Environment for a child process/session: depth counters increment so a
80
+ * child's own spawns hit the cap.
81
+ */
82
+ export function childDepthEnv(env: NodeJS.ProcessEnv, maxDepth: number): Record<string, string> {
83
+ const currentRaw = env[CURRENT_SUBAGENT_DEPTH_ENV];
84
+ const current = currentRaw !== undefined && currentRaw.trim() !== "" ? Number(currentRaw) : 0;
85
+ return {
86
+ [MAX_SUBAGENT_DEPTH_ENV]: String(maxDepth),
87
+ [CURRENT_SUBAGENT_DEPTH_ENV]: String(Number.isInteger(current) && current >= 0 ? current + 1 : 1),
88
+ };
89
+ }
90
+
91
+ /** Whether spawning is blocked by the depth guard for this environment. */
92
+ export function depthExceeded(env: NodeJS.ProcessEnv, maxDepth: number): boolean {
93
+ const currentRaw = env[CURRENT_SUBAGENT_DEPTH_ENV];
94
+ if (currentRaw === undefined || currentRaw.trim() === "") return false;
95
+ const current = Number(currentRaw);
96
+ return Number.isInteger(current) && current >= maxDepth;
97
+ }
98
+
99
+ // ============================================================================
100
+ // Context resolution (in-process foreground)
101
+ // ============================================================================
102
+
103
+ export class ContextUnavailableError extends Error {
104
+ constructor(message: string) {
105
+ super(message);
106
+ this.name = "ContextUnavailableError";
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Resolve launch context: explicit call value > config defaultSubagentContext >
112
+ * agent defaultContext > "fresh". Explicit fork on the IN-PROCESS path fails
113
+ * fast with a pointer to the async path (reference behavior: explicit fork
114
+ * never silently downgrades).
115
+ */
116
+ export function resolveContext(
117
+ callContext: string | undefined,
118
+ agent: AgentConfig | undefined,
119
+ config: SubagentsConfig | undefined,
120
+ ): ContextMode {
121
+ if (callContext === "fresh" || callContext === "fork") return callContext;
122
+ if (callContext === "profile") {
123
+ return agent?.defaultContext ?? "fresh";
124
+ }
125
+ const configDefault = config?.defaultSubagentContext;
126
+ if (configDefault === "fresh" || configDefault === "fork") return configDefault;
127
+ return agent?.defaultContext ?? "fresh";
128
+ }
129
+
130
+ /**
131
+ * In-process foreground runs are always fresh-context. An explicit fork
132
+ * request rejects with guidance (Phase 3's process runner provides fork).
133
+ */
134
+ export function assertForegroundContextSupported(mode: ContextMode, async: boolean): void {
135
+ if (mode === "fork" && !async) {
136
+ throw new ContextUnavailableError(
137
+ 'context: "fork" requires background execution (run_in_background: true); the in-process foreground path is fresh-context only. Re-run with run_in_background, or use context: "fresh".',
138
+ );
139
+ }
140
+ }
package/src/config.ts ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * @pi-unipi/subagents — Config management
3
+ *
4
+ * Loads config from ~/.unipi/config/subagents.json (global)
5
+ * and <workspace>/.unipi/config/subagents.json (override).
6
+ * Auto-generates on first run. Repairs corrupted files.
7
+ */
8
+
9
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import type { SubagentsConfig } from "./types.js";
13
+
14
+ const DEFAULT_CONFIG: SubagentsConfig = {
15
+ maxConcurrent: 4,
16
+ enabled: true,
17
+ types: {
18
+ explore: { enabled: true },
19
+ work: { enabled: true },
20
+ },
21
+ };
22
+
23
+ /** Get global config path: ~/.unipi/config/subagents.json */
24
+ function getGlobalConfigPath(): string {
25
+ return join(homedir(), ".unipi", "config", "subagents.json");
26
+ }
27
+
28
+ /** Get workspace config path: <cwd>/.unipi/config/subagents.json */
29
+ function getWorkspaceConfigPath(cwd: string): string {
30
+ return join(cwd, ".unipi", "config", "subagents.json");
31
+ }
32
+
33
+ /** Ensure directory exists. */
34
+ function ensureDir(filePath: string): void {
35
+ const dir = filePath.substring(0, filePath.lastIndexOf("/"));
36
+ if (!existsSync(dir)) {
37
+ mkdirSync(dir, { recursive: true });
38
+ }
39
+ }
40
+
41
+ /** Ensure a directory exists (not a file path). */
42
+ function ensureDirExists(dirPath: string): void {
43
+ if (!existsSync(dirPath)) {
44
+ mkdirSync(dirPath, { recursive: true });
45
+ }
46
+ }
47
+
48
+ /** Write config atomically (write then rename). */
49
+ function writeConfigAtomic(filePath: string, config: SubagentsConfig): void {
50
+ const tmpPath = filePath + ".tmp";
51
+ writeFileSync(tmpPath, JSON.stringify(config, null, 2), "utf-8");
52
+ renameSync(tmpPath, filePath);
53
+ }
54
+
55
+ /** Load and parse config from a path. Returns null on failure. */
56
+ function loadConfigFromPath(filePath: string): SubagentsConfig | null {
57
+ if (!existsSync(filePath)) return null;
58
+
59
+ try {
60
+ const content = readFileSync(filePath, "utf-8");
61
+ const parsed = JSON.parse(content);
62
+ // Basic validation
63
+ if (typeof parsed !== "object" || parsed === null) return null;
64
+ return parsed as SubagentsConfig;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Validate parity keys. Follows the reference behavior: invalid values for
72
+ * strict keys (toolTimeoutMs, budgets, concurrency caps) are rejected with a
73
+ * visible error rather than silently ignored. Best-effort keys (placement,
74
+ * logging mode) fall back to defaults like the reference does.
75
+ *
76
+ * Returns a list of validation problems; empty means valid.
77
+ */
78
+ export function validateParityConfig(config: SubagentsConfig): string[] {
79
+ const problems: string[] = [];
80
+ const positiveInt = (label: string, value: unknown): void => {
81
+ if (value === undefined) return;
82
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0 || value > 2147483647) {
83
+ problems.push(`${label} must be a positive integer no greater than 2147483647 (got ${JSON.stringify(value)})`);
84
+ }
85
+ };
86
+
87
+ positiveInt("timeoutMs", config.timeoutMs);
88
+ positiveInt("toolTimeoutMs", config.toolTimeoutMs);
89
+ positiveInt("globalConcurrencyLimit", config.globalConcurrencyLimit);
90
+ if (config.maxSubagentSpawnsPerSession !== undefined && config.maxSubagentSpawnsPerSession !== 0) {
91
+ positiveInt("maxSubagentSpawnsPerSession", config.maxSubagentSpawnsPerSession);
92
+ }
93
+ if (config.maxSubagentSpawnsPerRun !== undefined && config.maxSubagentSpawnsPerRun !== 0) {
94
+ positiveInt("maxSubagentSpawnsPerRun", config.maxSubagentSpawnsPerRun);
95
+ }
96
+ if (config.maxActiveAsyncRunsPerSession !== undefined && config.maxActiveAsyncRunsPerSession !== 0) {
97
+ positiveInt("maxActiveAsyncRunsPerSession", config.maxActiveAsyncRunsPerSession);
98
+ }
99
+ if (config.maxSubagentDepth !== undefined) positiveInt("maxSubagentDepth", config.maxSubagentDepth);
100
+
101
+ if (config.defaultSubagentContext !== undefined && !['"fresh"', '"fork"'].includes(JSON.stringify(config.defaultSubagentContext))) {
102
+ problems.push(`defaultSubagentContext must be "fresh" or "fork" (got ${JSON.stringify(config.defaultSubagentContext)})`);
103
+ }
104
+ if (config.fleetViewPlacement !== undefined && !['"belowEditor"', '"aboveEditor"'].includes(JSON.stringify(config.fleetViewPlacement))) {
105
+ problems.push(`fleetViewPlacement must be "belowEditor" or "aboveEditor" (got ${JSON.stringify(config.fleetViewPlacement)}); falling back to "belowEditor"`);
106
+ }
107
+ if (
108
+ config.resultScanLogging !== undefined &&
109
+ !["all", "activity", "off"].includes(config.resultScanLogging)
110
+ ) {
111
+ problems.push(`resultScanLogging must be "all", "activity", or "off" (got ${JSON.stringify(config.resultScanLogging)})`);
112
+ }
113
+ if (config.inlineToolDisplay !== undefined && !["rich", "summary"].includes(config.inlineToolDisplay)) {
114
+ problems.push(`inlineToolDisplay must be "rich" or "summary" (got ${JSON.stringify(config.inlineToolDisplay)})`);
115
+ }
116
+ if (config.parallel) {
117
+ positiveInt("parallel.maxTasks", config.parallel.maxTasks);
118
+ positiveInt("parallel.concurrency", config.parallel.concurrency);
119
+ }
120
+ if (config.maxOutput) {
121
+ positiveInt("maxOutput.bytes", config.maxOutput.bytes);
122
+ positiveInt("maxOutput.lines", config.maxOutput.lines);
123
+ }
124
+
125
+ return problems;
126
+ }
127
+
128
+ /** Repair corrupted config: rename to .bak and generate fresh. */
129
+ function repairCorrupted(filePath: string): SubagentsConfig {
130
+ const backupPath = filePath + ".bak";
131
+ try {
132
+ renameSync(filePath, backupPath);
133
+ } catch {
134
+ // If rename fails, just overwrite
135
+ }
136
+ writeConfigAtomic(filePath, DEFAULT_CONFIG);
137
+ return DEFAULT_CONFIG;
138
+ }
139
+
140
+ /**
141
+ * Initialize config on extension start.
142
+ * - If missing: generate with defaults
143
+ * - If corrupted: rename to .bak, generate fresh
144
+ * - If valid: load
145
+ */
146
+ export function initConfig(cwd: string): SubagentsConfig {
147
+ const globalPath = getGlobalConfigPath();
148
+ const globalDir = join(homedir(), ".unipi", "config");
149
+ const globalAgentsDir = join(homedir(), ".unipi", "config", "agents");
150
+
151
+ // Ensure directories exist
152
+ ensureDirExists(globalDir);
153
+ ensureDirExists(globalAgentsDir);
154
+
155
+ // Load or create global config
156
+ let globalConfig = loadConfigFromPath(globalPath);
157
+ if (globalConfig === null) {
158
+ globalConfig = repairCorrupted(globalPath);
159
+ }
160
+
161
+ // Ensure workspace directories exist if workspace exists
162
+ const workspaceDir = join(cwd, ".unipi", "config");
163
+ const workspaceAgentsDir = join(cwd, ".unipi", "config", "agents");
164
+ if (cwd && !cwd.startsWith(homedir())) {
165
+ // Only create workspace dirs if not in home directory
166
+ ensureDirExists(workspaceDir);
167
+ ensureDirExists(workspaceAgentsDir);
168
+ }
169
+
170
+ // Load workspace override if exists
171
+ const workspacePath = getWorkspaceConfigPath(cwd);
172
+ const workspaceConfig = loadConfigFromPath(workspacePath);
173
+
174
+ if (workspaceConfig) {
175
+ // Merge: workspace overrides global on any field present
176
+ const merged: SubagentsConfig = {
177
+ ...globalConfig,
178
+ ...workspaceConfig,
179
+ types: {
180
+ ...globalConfig.types,
181
+ ...workspaceConfig.types,
182
+ },
183
+ };
184
+ reportConfigProblems(merged);
185
+ return merged;
186
+ }
187
+
188
+ reportConfigProblems(globalConfig);
189
+ return globalConfig;
190
+ }
191
+
192
+ /** Surface config validation problems visibly (non-fatal, reference behavior). */
193
+ function reportConfigProblems(config: SubagentsConfig): void {
194
+ const problems = validateParityConfig(config);
195
+ for (const problem of problems) {
196
+ console.error(`[unipi/subagents] config: ${problem}`);
197
+ }
198
+ }
199
+
200
+ /** Load the RAW parsed global config (for parity settings extraction). */
201
+ export function loadRawGlobalConfig(): Record<string, unknown> | null {
202
+ return loadConfigFromPath(getGlobalConfigPath()) as Record<string, unknown> | null;
203
+ }
204
+
205
+ /** Load the RAW parsed workspace config (for parity settings extraction). */
206
+ export function loadRawWorkspaceConfig(cwd: string): Record<string, unknown> | null {
207
+ return loadConfigFromPath(getWorkspaceConfigPath(cwd)) as Record<string, unknown> | null;
208
+ }
209
+
210
+ /**
211
+ * Save global config.
212
+ */
213
+ export function saveGlobalConfig(config: SubagentsConfig): void {
214
+ const globalPath = getGlobalConfigPath();
215
+ ensureDir(globalPath);
216
+ writeConfigAtomic(globalPath, config);
217
+ }
218
+
219
+ /**
220
+ * Save workspace config.
221
+ */
222
+ export function saveWorkspaceConfig(cwd: string, config: SubagentsConfig): void {
223
+ const workspacePath = getWorkspaceConfigPath(cwd);
224
+ ensureDir(workspacePath);
225
+ writeConfigAtomic(workspacePath, config);
226
+ }