@yefengr/pi-subagents 0.0.0-stage → 0.1.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 (103) hide show
  1. package/CHANGELOG.md +2411 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -2
  4. package/UPSTREAM.md +48 -0
  5. package/dist/public.d.ts +201 -0
  6. package/dist/settings.d.ts +82 -0
  7. package/docs/architecture/architecture.md +833 -0
  8. package/docs/architecture/client-server-opportunities.md +127 -0
  9. package/docs/architecture/history/phase-1-api-boundary.md +8 -0
  10. package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
  11. package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
  12. package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
  13. package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
  14. package/docs/architecture/history/phase-14-strip-policy.md +49 -0
  15. package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
  16. package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
  17. package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
  18. package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
  19. package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
  20. package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
  21. package/docs/architecture/history/phase-20-result-delivery.md +245 -0
  22. package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
  23. package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
  24. package/docs/architecture/history/phase-4-implement-service.md +8 -0
  25. package/docs/architecture/history/phase-5-decompose-index.md +42 -0
  26. package/docs/architecture/history/phase-7-encapsulation.md +173 -0
  27. package/docs/architecture/history/phase-8-testability.md +103 -0
  28. package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
  29. package/docs/comparison-with-upstream.md +79 -0
  30. package/docs/configuration.md +215 -0
  31. package/docs/decisions/0001-deferred-patches.md +80 -0
  32. package/docs/decisions/0002-extensions-on-a-minimal-core.md +114 -0
  33. package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
  34. package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
  35. package/index.ts +1 -0
  36. package/package.json +91 -4
  37. package/src/config/agent-enablement.ts +132 -0
  38. package/src/config/agent-types.ts +144 -0
  39. package/src/config/custom-agents.ts +114 -0
  40. package/src/config/default-agents.ts +120 -0
  41. package/src/config/invocation-config.ts +39 -0
  42. package/src/config/resource-limits.ts +26 -0
  43. package/src/config/settings-file.ts +174 -0
  44. package/src/debug.ts +14 -0
  45. package/src/handlers/index.ts +3 -0
  46. package/src/handlers/interrupt.ts +58 -0
  47. package/src/handlers/lifecycle.ts +65 -0
  48. package/src/handlers/tool-start.ts +32 -0
  49. package/src/index.ts +257 -0
  50. package/src/layered-settings.ts +105 -0
  51. package/src/lifecycle/child-lifecycle.ts +89 -0
  52. package/src/lifecycle/child-shutdown.ts +105 -0
  53. package/src/lifecycle/concurrency-limiter.ts +123 -0
  54. package/src/lifecycle/create-subagent-session.ts +265 -0
  55. package/src/lifecycle/invocation-control.ts +109 -0
  56. package/src/lifecycle/lifecycle-record.ts +159 -0
  57. package/src/lifecycle/parent-snapshot.ts +46 -0
  58. package/src/lifecycle/run-listeners.ts +41 -0
  59. package/src/lifecycle/subagent-manager.ts +392 -0
  60. package/src/lifecycle/subagent-session.ts +287 -0
  61. package/src/lifecycle/subagent-state.ts +307 -0
  62. package/src/lifecycle/subagent.ts +854 -0
  63. package/src/lifecycle/usage.ts +60 -0
  64. package/src/lifecycle/workspace-bracket.ts +59 -0
  65. package/src/lifecycle/workspace.ts +47 -0
  66. package/src/observation/composite-subagent-observer.ts +53 -0
  67. package/src/observation/notification.ts +233 -0
  68. package/src/observation/record-observer.ts +78 -0
  69. package/src/observation/renderer.ts +106 -0
  70. package/src/observation/subagent-events-observer.ts +75 -0
  71. package/src/runtime.ts +78 -0
  72. package/src/service/service-adapter.ts +159 -0
  73. package/src/service/service.ts +147 -0
  74. package/src/session/content-items.ts +53 -0
  75. package/src/session/context.ts +80 -0
  76. package/src/session/conversation.ts +49 -0
  77. package/src/session/env.ts +40 -0
  78. package/src/session/model-resolver.ts +135 -0
  79. package/src/session/package-exclusions.ts +75 -0
  80. package/src/session/prompts.ts +145 -0
  81. package/src/session/provider-inheritance.ts +38 -0
  82. package/src/session/session-config.ts +177 -0
  83. package/src/session/session-dir.ts +38 -0
  84. package/src/settings.ts +436 -0
  85. package/src/tools/agent-tool.ts +289 -0
  86. package/src/tools/background-spawner.ts +73 -0
  87. package/src/tools/foreground-runner.ts +135 -0
  88. package/src/tools/get-result-report.ts +73 -0
  89. package/src/tools/get-result-tool.ts +109 -0
  90. package/src/tools/helpers.ts +134 -0
  91. package/src/tools/result-renderer.ts +121 -0
  92. package/src/tools/spawn-config.ts +182 -0
  93. package/src/tools/steer-tool.ts +120 -0
  94. package/src/types.ts +145 -0
  95. package/src/ui/agent-widget.ts +313 -0
  96. package/src/ui/display.ts +214 -0
  97. package/src/ui/glyphs.ts +62 -0
  98. package/src/ui/session-navigation.ts +152 -0
  99. package/src/ui/session-navigator.ts +257 -0
  100. package/src/ui/subagents-agents.ts +116 -0
  101. package/src/ui/subagents-settings.ts +207 -0
  102. package/src/ui/transcript-content.ts +374 -0
  103. package/src/ui/widget-renderer.ts +289 -0
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Model resolution: exact match ("provider/modelId") with fuzzy fallback.
3
+ */
4
+ import type { Model } from "@earendil-works/pi-ai";
5
+
6
+ export interface ModelRegistry {
7
+ find(provider: string, modelId: string): Model<any> | undefined;
8
+ getAll(): Model<any>[];
9
+ getAvailable?(): Model<any>[];
10
+ }
11
+
12
+ /** Successful model resolution — `model` is the resolved or inherited model instance. */
13
+ export interface ModelResolutionResult {
14
+ model: Model<any> | undefined;
15
+ error?: undefined;
16
+ }
17
+
18
+ /** Failed resolution of an explicitly specified model (agent profile or tool parameter). */
19
+ export interface ModelResolutionError {
20
+ model?: undefined;
21
+ error: string;
22
+ }
23
+
24
+ /** Discriminated union returned by `resolveInvocationModel`. */
25
+ export type ModelResolution = ModelResolutionResult | ModelResolutionError;
26
+
27
+ /**
28
+ * Resolve the effective model for an agent invocation.
29
+ *
30
+ * Encapsulates the fallback policy used in `Agent.execute`:
31
+ * 1. No `modelInput` (`undefined`) → inherit `parentModel`.
32
+ * 2. `modelInput` resolves → return the resolved model.
33
+ * 3. `modelInput` fails → return `{ error }`. A model chosen explicitly (agent profile
34
+ * or tool parameter) is never replaced by the parent model; `modelFromParams` only
35
+ * labels the error's origin. An explicit empty or all-whitespace value also fails:
36
+ * only omission inherits.
37
+ */
38
+ export function resolveInvocationModel(
39
+ parentModel: Model<any> | undefined,
40
+ modelInput: string | undefined,
41
+ modelFromParams: boolean,
42
+ registry: ModelRegistry | undefined,
43
+ ): ModelResolution {
44
+ if (modelInput === undefined) return { model: parentModel };
45
+ if (!registry) return { error: "No model registry available." };
46
+ const resolved = resolveModel(modelInput, registry);
47
+ if (typeof resolved !== "string") return { model: resolved };
48
+ const source = modelFromParams ? "tool parameter" : "agent profile";
49
+ return { error: `${resolved}\n\nExplicit model from ${source} must resolve; not inheriting the parent model.` };
50
+ }
51
+
52
+ /**
53
+ * Resolve a model string to a Model instance.
54
+ * A qualified "provider/modelId" is an exact pin against available models: it never
55
+ * falls back to fuzzy matching, so a typo, another provider, or an unauthenticated
56
+ * model fails instead of silently selecting a sibling. Qualified input is compared
57
+ * case-insensitively, but the registry lookup uses the matched model's canonical
58
+ * provider/id. Unqualified input is fuzzy-matched. Empty or all-whitespace input is
59
+ * invalid rather than a query that matches everything.
60
+ * Returns the Model on success, or an error message string on failure.
61
+ */
62
+ export function resolveModel(
63
+ input: string,
64
+ registry: ModelRegistry,
65
+ ): Model<any> | string {
66
+ // Available models (those with auth configured)
67
+ const all = registry.getAvailable?.() ?? registry.getAll();
68
+
69
+ // 0. Empty or all-whitespace values are invalid explicit models, not fuzzy queries.
70
+ if (input.trim() === "") return formatNotFound(input, all);
71
+
72
+ // 1. Qualified "provider/modelId": exact pin only — no fuzzy fallback
73
+ const slashIdx = input.indexOf("/");
74
+ if (slashIdx !== -1) {
75
+ const matched = all.find(m => `${m.provider}/${m.id}`.toLowerCase() === input.toLowerCase());
76
+ if (matched) {
77
+ const found = registry.find(matched.provider, matched.id);
78
+ if (found) return found;
79
+ }
80
+ return formatNotFound(input, all);
81
+ }
82
+
83
+ // 2. Fuzzy match unqualified names against available models
84
+ const bestMatch = findBestFuzzyMatch(all, input.toLowerCase());
85
+ if (bestMatch) {
86
+ const found = registry.find(bestMatch.provider, bestMatch.id);
87
+ if (found) return found;
88
+ }
89
+
90
+ // 3. No match — list available models
91
+ return formatNotFound(input, all);
92
+ }
93
+
94
+ /** Build the shared "model not found" message with the available-model list. */
95
+ function formatNotFound(input: string, all: Model<any>[]): string {
96
+ const modelList = all
97
+ .map(m => ` ${m.provider}/${m.id}`)
98
+ .sort()
99
+ .join("\n");
100
+ return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
101
+ }
102
+
103
+ /**
104
+ * Score each candidate model — prefer exact id match > id contains > name
105
+ * contains > provider+id contains — and return the best match at or above
106
+ * the acceptance threshold (20), or undefined if nothing scores high enough.
107
+ */
108
+ function findBestFuzzyMatch(all: Model<any>[], query: string): Model<any> | undefined {
109
+ let bestMatch: Model<any> | undefined;
110
+ let bestScore = 0;
111
+
112
+ for (const m of all) {
113
+ const id = m.id.toLowerCase();
114
+ const name = m.name.toLowerCase();
115
+ const full = `${m.provider}/${m.id}`.toLowerCase();
116
+
117
+ let score = 0;
118
+ if (id === query || full === query) {
119
+ score = 100; // exact
120
+ } else if (id.includes(query) || full.includes(query)) {
121
+ score = 60 + (query.length / id.length) * 30; // substring, prefer tighter matches
122
+ } else if (name.includes(query)) {
123
+ score = 40 + (query.length / name.length) * 20;
124
+ } else if (query.split(/[\s\-/]+/).every(part => id.includes(part) || name.includes(part) || m.provider.toLowerCase().includes(part))) {
125
+ score = 20; // all parts present somewhere
126
+ }
127
+
128
+ if (score > bestScore) {
129
+ bestScore = score;
130
+ bestMatch = m;
131
+ }
132
+ }
133
+
134
+ return bestScore >= 20 ? bestMatch : undefined;
135
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * package-exclusions.ts — build a child-local view of Pi's package settings
3
+ * that disables selected packages' extensions (issue #696).
4
+ *
5
+ * Pi resolves a child session's resources from `Settings.packages`. An entry in
6
+ * object form with `extensions: []` loads none of that package's extensions in
7
+ * both of Pi's filter modes — the default mode treats the empty array as an
8
+ * explicit disable, and `autoload: false` delta mode starts empty and only adds
9
+ * explicitly listed patterns. Filtering here, at resolution time, means the
10
+ * excluded package's extension module is never imported and its factory never
11
+ * runs, which is what makes this a prevent-load seam rather than a deny-at-use
12
+ * one. The package's skills, prompts, and themes are untouched.
13
+ */
14
+
15
+ import type { PackageSource, SettingsManager } from "@earendil-works/pi-coding-agent";
16
+
17
+ /** Pi does not export `Settings` from its package root; derive it from the accessor. */
18
+ type PiSettings = ReturnType<SettingsManager["getGlobalSettings"]>;
19
+
20
+ /** Pi does not export `SettingsStorage` either; derive it from the public factory. */
21
+ type PiSettingsStorage = Parameters<typeof SettingsManager.fromStorage>[0];
22
+
23
+ /** The parent-settings reads a child view needs — narrower than `SettingsManager`. */
24
+ export interface ParentSettingsView {
25
+ getGlobalSettings(): PiSettings;
26
+ getProjectSettings(): PiSettings;
27
+ }
28
+
29
+ /**
30
+ * A storage backend that serves the parent's settings with the excluded
31
+ * packages' extensions disabled, for `SettingsManager.fromStorage`.
32
+ *
33
+ * Reads pass through to `parent` on every load, so a `reload()` reflects the
34
+ * parent's current values rather than a construction-time snapshot.
35
+ *
36
+ * Writes are discarded deliberately. `withLock` persists whatever its callback
37
+ * returns, and the callback here is handed synthesized `extensions: []` entries;
38
+ * forwarding that write would disable those packages in the user's real
39
+ * settings file, for the parent and every future session.
40
+ */
41
+ export function createExcludedPackagesStorage(
42
+ parent: ParentSettingsView,
43
+ excluded: ReadonlySet<string>,
44
+ ): PiSettingsStorage {
45
+ return {
46
+ withLock(scope, fn) {
47
+ const settings = scope === "global" ? parent.getGlobalSettings() : parent.getProjectSettings();
48
+ fn(JSON.stringify(withPackageExtensionsDisabled(settings, excluded)));
49
+ },
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Return `settings` with every excluded package's `extensions` filter emptied.
55
+ * Non-matched entries are returned by identity and the input is never mutated.
56
+ */
57
+ export function withPackageExtensionsDisabled(
58
+ settings: PiSettings,
59
+ excluded: ReadonlySet<string>,
60
+ ): PiSettings {
61
+ if (!settings.packages || excluded.size === 0) return settings;
62
+ return {
63
+ ...settings,
64
+ packages: settings.packages.map((pkg) => disableExtensionsIfExcluded(pkg, excluded)),
65
+ };
66
+ }
67
+
68
+ function disableExtensionsIfExcluded(
69
+ pkg: PackageSource,
70
+ excluded: ReadonlySet<string>,
71
+ ): PackageSource {
72
+ const source = typeof pkg === "string" ? pkg : pkg.source;
73
+ if (!excluded.has(source)) return pkg;
74
+ return typeof pkg === "string" ? { source, extensions: [] } : { ...pkg, extensions: [] };
75
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * prompts.ts — System prompt builder for agents.
3
+ */
4
+
5
+ import type { EnvInfo } from "#src/session/env";
6
+ import type { AgentPromptConfig } from "#src/types";
7
+
8
+ /** The parent session's contribution to a child prompt, plus the cwd that text claims. */
9
+ export interface InheritedPrompt {
10
+ /** The parent agent's effective system prompt. */
11
+ systemPrompt: string;
12
+ /** The parent's working directory — the cwd its prompt footer names. */
13
+ cwd: string;
14
+ }
15
+
16
+ /**
17
+ * Build the system prompt for an agent from its config.
18
+ *
19
+ * Both modes place the shared/stable parent prompt (or `genericBase` when no
20
+ * parent is available) first so the LLM's KV cache can reuse the inherited
21
+ * prefix across all subagent invocations.
22
+ *
23
+ * - "replace" mode: parent/genericBase + active_agent tag + env header +
24
+ * config.systemPrompt. No `<sub_agent_context>` bridge and no
25
+ * `<agent_instructions>` wrapper — the custom prompt has full control and
26
+ * the final say.
27
+ * - "append" mode: parent/genericBase + sub-agent context bridge +
28
+ * active_agent tag + env header + config.systemPrompt (wrapped in
29
+ * `<agent_instructions>` when non-empty).
30
+ * - "append" with empty systemPrompt: pure parent clone.
31
+ *
32
+ * Both modes include an `<active_agent name="${config.name}"/>` tag so
33
+ * downstream extensions (e.g. `@gotgenes/pi-permission-system`) can resolve
34
+ * per-agent policy inside the child session by parsing the system prompt.
35
+ * The tag follows the cacheable parent prefix in both modes.
36
+ *
37
+ * @param inherited The parent agent's effective system prompt and the cwd it names.
38
+ */
39
+ export function buildAgentPrompt(
40
+ config: AgentPromptConfig,
41
+ cwd: string,
42
+ env: EnvInfo,
43
+ inherited?: InheritedPrompt,
44
+ ): string {
45
+ const activeAgentTag = `<active_agent name="${config.name}"/>\n\n`;
46
+
47
+ const envBlock = `# Environment
48
+ Working directory: ${cwd}
49
+ ${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"}
50
+ Platform: ${env.platform}`;
51
+
52
+ const identity = inherited
53
+ ? withoutContradictoryCwdFooter(inherited.systemPrompt, inherited.cwd, cwd)
54
+ : genericBase;
55
+
56
+ if (config.promptMode === "append") {
57
+
58
+ const bridge = `<sub_agent_context>
59
+ You are operating as a sub-agent invoked to handle a specific task.
60
+ - Use the read tool instead of cat/head/tail
61
+ - Use the edit tool instead of sed/awk
62
+ - Use the write tool instead of echo/heredoc
63
+ - Use the find tool instead of bash find/ls for file search
64
+ - Use the grep tool instead of bash grep/rg for content search
65
+ - Make independent tool calls in parallel
66
+ - Use absolute file paths
67
+ - Do not use emojis
68
+ - Be concise but complete
69
+ </sub_agent_context>`;
70
+
71
+ const customSection = config.systemPrompt.trim()
72
+ ? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
73
+ : "";
74
+
75
+ // Place shared/stable content first so the LLM's KV cache can reuse the
76
+ // inherited prefix across all subagent invocations. The parent prompt is
77
+ // placed verbatim (no wrapper tag) so it forms an identical byte prefix
78
+ // with the parent session, maximising KV cache hits. The <active_agent>
79
+ // tag and env block vary per call and are placed after the cached prefix.
80
+ return (
81
+ identity +
82
+ "\n\n" +
83
+ bridge +
84
+ "\n\n" +
85
+ activeAgentTag +
86
+ envBlock +
87
+ customSection
88
+ );
89
+ }
90
+
91
+ // "replace" mode — parent/genericBase prefix first for KV cache reuse, then
92
+ // the active_agent tag, env block, and the config's full system prompt.
93
+ // Unlike append mode, no <sub_agent_context> bridge or <agent_instructions>
94
+ // wrapper is injected — the custom prompt retains full control.
95
+ return identity + "\n\n" + activeAgentTag + envBlock + "\n\n" + config.systemPrompt;
96
+ }
97
+
98
+ /**
99
+ * Remove the parent's `Current working directory:` footer from the prompt the
100
+ * child inherits, when it names a different directory than the child's.
101
+ *
102
+ * Pi's `buildSystemPrompt` ends every prompt with that footer and appends a
103
+ * fresh one — naming the child session's own cwd — after this string. Left in
104
+ * place, the inherited line gives a workspace-isolated child (e.g. one placed
105
+ * in a git worktree by a `WorkspaceProvider`) a second, stale claim in the
106
+ * exact phrasing Pi uses for the authoritative one, and the child follows it
107
+ * back into the parent's directory (#640).
108
+ *
109
+ * A child sharing the parent's directory inherits a footer that agrees with its
110
+ * own, so the prompt is returned untouched — keeping the inherited prefix
111
+ * byte-identical to the parent's for prefix-caching providers. Editing it would
112
+ * cost shared prefix (the parent's trailing extension-appended blocks shift
113
+ * offset) to delete an accurate duplicate.
114
+ *
115
+ * The match is whole-line, so a footer naming a directory that merely shares a
116
+ * prefix with the parent's survives, and it mirrors the separator normalization
117
+ * `buildSystemPrompt` applies. An unmatched prompt is returned unchanged.
118
+ */
119
+ function withoutContradictoryCwdFooter(
120
+ prompt: string,
121
+ parentCwd: string,
122
+ childCwd: string,
123
+ ): string {
124
+ const inheritedClaim = toPromptPath(parentCwd);
125
+ if (inheritedClaim === toPromptPath(childCwd)) {
126
+ return prompt;
127
+ }
128
+
129
+ const footerLine = `Current working directory: ${inheritedClaim}`;
130
+ return prompt
131
+ .split("\n")
132
+ .filter((line) => line !== footerLine)
133
+ .join("\n");
134
+ }
135
+
136
+ /** Render a path the way `buildSystemPrompt` writes it into a prompt. */
137
+ function toPromptPath(cwd: string): string {
138
+ return cwd.replaceAll("\\", "/");
139
+ }
140
+
141
+ /** Fallback base prompt when parent system prompt is unavailable (both modes). */
142
+ const genericBase = `# Role
143
+ You are a general-purpose coding agent for complex, multi-step tasks.
144
+ You have full access to read, write, edit files, and execute commands.
145
+ Do what has been asked; nothing more, nothing less.`;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * 将父会话运行时注册的 provider 复制到子会话独立的 model runtime。
3
+ *
4
+ * Pi 的 provider 注册保存在 ModelRuntime 实例内,不会写入 models.json;
5
+ * 子会话创建独立 runtime 时必须显式继承,否则自定义 provider 无法工作。
6
+ */
7
+
8
+ /** 枚举会话运行时注册的 provider。 */
9
+ export interface RegisteredProviderSource<TNative, TConfig> {
10
+ getRegisteredProviderIds(): readonly string[];
11
+ getRegisteredNativeProvider(id: string): TNative | undefined;
12
+ getRegisteredProviderConfig(id: string): TConfig | undefined;
13
+ }
14
+
15
+ /** 接收复制后的原生或配置型 provider。 */
16
+ export interface ProviderRegistrar<TNative, TConfig> {
17
+ registerNative(provider: TNative): void;
18
+ registerConfigured(id: string, config: TConfig): void;
19
+ }
20
+
21
+ /**
22
+ * 按父 registry 的枚举顺序复制全部运行时注册项。
23
+ * 原生与配置型注册理论上互斥;异常情况下优先保留原生 provider。
24
+ */
25
+ export function inheritRegisteredProviders<TNative, TConfig>(
26
+ source: RegisteredProviderSource<TNative, TConfig>,
27
+ target: ProviderRegistrar<TNative, TConfig>,
28
+ ): void {
29
+ for (const id of source.getRegisteredProviderIds()) {
30
+ const native = source.getRegisteredNativeProvider(id);
31
+ if (native !== undefined) {
32
+ target.registerNative(native);
33
+ continue;
34
+ }
35
+ const config = source.getRegisteredProviderConfig(id);
36
+ if (config !== undefined) target.registerConfigured(id, config);
37
+ }
38
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * session-config.ts — Pure configuration assembler for agent sessions.
3
+ *
4
+ * `assembleSessionConfig()` is the pure assembly core called by
5
+ * `createSubagentSession()`. It accepts resolved inputs (agent type, narrow
6
+ * context, run options, env info) and returns everything the factory needs to
7
+ * create the SDK session — without importing or constructing any Pi SDK types.
8
+ *
9
+ * The only async IO in the assembly phase (`detectEnv`) is handled by the caller
10
+ * before invoking this function, keeping the assembler synchronous.
11
+ */
12
+
13
+ import type { Model } from "@earendil-works/pi-ai";
14
+ import type { AgentConfigLookup } from "#src/config/agent-types";
15
+ import type { EnvInfo } from "#src/session/env";
16
+ import type { ModelRegistry } from "#src/session/model-resolver";
17
+ import type { InheritedPrompt } from "#src/session/prompts";
18
+ import type { AgentPromptConfig, SubagentType, ThinkingLevel } from "#src/types";
19
+
20
+ // ── Public interfaces ────────────────────────────────────────────────────────
21
+
22
+ /**
23
+ * IO collaborators injected into `assembleSessionConfig`.
24
+ *
25
+ * Bundling the IO-touching (or promptly testable) function into a single
26
+ * interface keeps the assembler free of direct module imports and makes it
27
+ * trivially testable without `vi.mock()` — callers inject real implementations
28
+ * at the edge (`create-subagent-session.ts`) or stubs in tests.
29
+ */
30
+ export interface AssemblerIO {
31
+ buildAgentPrompt: (
32
+ config: AgentPromptConfig,
33
+ cwd: string,
34
+ env: EnvInfo,
35
+ inherited?: InheritedPrompt,
36
+ ) => string;
37
+ }
38
+
39
+ /**
40
+ * Narrow context the assembler reads from the parent session.
41
+ * Tests construct plain objects satisfying this interface — no SDK mocking needed.
42
+ *
43
+ * The assembler never inspects model internals, only passes them through;
44
+ * `resolveDefaultModel` reads `provider`/`id` from `getAvailable()` for the
45
+ * availability check.
46
+ */
47
+ export interface AssemblerContext {
48
+ /** Parent working directory (overridable via options.cwd). */
49
+ cwd: string;
50
+ /** Parent's effective system prompt (for append-mode agents). */
51
+ parentSystemPrompt: string;
52
+ /** Parent's current model instance (fallback when agent config has no model). */
53
+ parentModel?: Model<any>;
54
+ /** Model registry for resolving config.model strings. */
55
+ modelRegistry: ModelRegistry;
56
+ }
57
+
58
+ /**
59
+ * Narrow slice of per-spawn execution fields consumed by the assembler.
60
+ * All fields are optional — callers pass only what they have.
61
+ */
62
+ export interface AssemblerOptions {
63
+ /** Override working directory (e.g. for worktree isolation). */
64
+ cwd?: string;
65
+ /** Explicit model override — wins over agentConfig.model and parent model. */
66
+ model?: Model<any>;
67
+ /** Explicit thinking level — wins over agentConfig.thinking. */
68
+ thinkingLevel?: ThinkingLevel;
69
+ }
70
+
71
+ /**
72
+ * Assembled configuration returned to `createSubagentSession()`.
73
+ * Contains everything needed to create the SDK session and filter tools —
74
+ * with no SDK object references.
75
+ */
76
+ export interface SessionConfig {
77
+ /** Resolved working directory (`options.cwd ?? ctx.cwd`). */
78
+ effectiveCwd: string;
79
+ /** Fully-assembled system prompt string (ready for `systemPromptOverride`). */
80
+ systemPrompt: string;
81
+ /** Built-in tool name allowlist for this agent type. */
82
+ toolNames: string[];
83
+ /**
84
+ * Resolved model instance (undefined → use parent model as passed to SDK).
85
+ * The assembler passes it through without inspection.
86
+ */
87
+ model: Model<any> | undefined;
88
+ /** Resolved thinking level (undefined → inherit from session). */
89
+ thinkingLevel: ThinkingLevel | undefined;
90
+ }
91
+
92
+ // ── Internal helpers ─────────────────────────────────────────────────────────
93
+
94
+ /**
95
+ * Resolve the default model from the agent config's model string.
96
+ *
97
+ * Priority: parentModel is the fallback; if `configModel` is a "provider/modelId"
98
+ * string that resolves against the registry AND is in the available set, return
99
+ * that model instead.
100
+ */
101
+ function resolveDefaultModel(
102
+ parentModel: Model<any> | undefined,
103
+ registry: AssemblerContext["modelRegistry"],
104
+ configModel?: string,
105
+ ): Model<any> | undefined {
106
+ if (configModel) {
107
+ const slashIdx = configModel.indexOf("/");
108
+ if (slashIdx !== -1) {
109
+ const provider = configModel.slice(0, slashIdx);
110
+ const modelId = configModel.slice(slashIdx + 1);
111
+
112
+ const available = registry.getAvailable?.();
113
+ const availableKeys = available
114
+ ? new Set(available.map((m) => `${m.provider}/${m.id}`))
115
+ : undefined;
116
+ const isAvailable = (p: string, id: string) =>
117
+ !availableKeys || availableKeys.has(`${p}/${id}`);
118
+
119
+ const found = registry.find(provider, modelId);
120
+ if (found && isAvailable(provider, modelId)) return found;
121
+ }
122
+ }
123
+ return parentModel;
124
+ }
125
+
126
+ // ── Public function ──────────────────────────────────────────────────────────
127
+
128
+ /**
129
+ * Assemble all configuration needed to create an agent session.
130
+ *
131
+ * Synchronous and side-effect-free — all IO is delegated through the `io`
132
+ * parameter. The caller is responsible for resolving `EnvInfo` beforehand
133
+ * via `detectEnv()`.
134
+ *
135
+ * @param type The subagent type name (case-insensitive registry lookup).
136
+ * @param ctx Narrow context from the parent session.
137
+ * @param options Per-call overrides (cwd, model, thinkingLevel).
138
+ * @param env Pre-resolved environment info from `detectEnv()`.
139
+ * @param registry Agent config lookup — provides resolveAgentConfig and getToolNamesForType.
140
+ * @param io IO collaborators (skill loader, memory builder, prompt builder).
141
+ */
142
+ export function assembleSessionConfig(
143
+ type: SubagentType,
144
+ ctx: AssemblerContext,
145
+ options: AssemblerOptions,
146
+ env: EnvInfo,
147
+ registry: AgentConfigLookup,
148
+ io: AssemblerIO,
149
+ ): SessionConfig {
150
+ const agentConfig = registry.resolveAgentConfig(type);
151
+
152
+ const effectiveCwd = options.cwd ?? ctx.cwd;
153
+
154
+ const toolNames = registry.getToolNamesForType(type);
155
+
156
+ // Build system prompt from the resolved agent config
157
+ const systemPrompt = io.buildAgentPrompt(agentConfig, effectiveCwd, env, {
158
+ systemPrompt: ctx.parentSystemPrompt,
159
+ cwd: ctx.cwd,
160
+ });
161
+
162
+ // Model resolution: explicit option > config model string > parent model
163
+ const model =
164
+ options.model ??
165
+ resolveDefaultModel(ctx.parentModel, ctx.modelRegistry, agentConfig.model);
166
+
167
+ // Thinking level: explicit option > agent config > undefined (inherit)
168
+ const thinkingLevel = options.thinkingLevel ?? agentConfig.thinking;
169
+
170
+ return {
171
+ effectiveCwd,
172
+ systemPrompt,
173
+ toolNames,
174
+ model,
175
+ thinkingLevel,
176
+ };
177
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * session-dir.ts — Pure function for deriving subagent session directories.
3
+ *
4
+ * Subagent sessions are nested under the parent session's basename so they are
5
+ * discoverable via the parent session path without cluttering the main session list.
6
+ */
7
+
8
+ import { tmpdir } from "node:os";
9
+ import { basename, dirname, join } from "node:path";
10
+
11
+ /**
12
+ * Derive the session directory for a subagent from the parent session file.
13
+ *
14
+ * Layout: `<parent-dir>/<parent-basename>/tasks/`
15
+ *
16
+ * Example:
17
+ * parent: `~/.pi/agent/sessions/--project--/2026-05-20T12-00-00Z_.jsonl`
18
+ * result: `~/.pi/agent/sessions/--project--/2026-05-20T12-00-00Z_/tasks`
19
+ *
20
+ * Falls back to a temp directory when the parent session is not persisted
21
+ * (e.g. API/headless mode where the parent uses `SessionManager.inMemory()`).
22
+ */
23
+ export function deriveSubagentSessionDir(
24
+ parentSessionFile: string | undefined,
25
+ cwd: string,
26
+ ): string {
27
+ if (parentSessionFile) {
28
+ const dir = dirname(parentSessionFile);
29
+ const base = basename(parentSessionFile, ".jsonl");
30
+ return join(dir, base, "tasks");
31
+ }
32
+
33
+ // Fallback: use a temp directory keyed by uid and cwd so different
34
+ // projects don't collide when the parent session is not persisted.
35
+ const encoded = cwd.replace(/[/\\]/g, "-").replace(/^[A-Za-z]:-/, "").replace(/^-+/, "");
36
+ const root = join(tmpdir(), `pi-subagent-${process.getuid?.() ?? 0}`);
37
+ return join(root, encoded, "tasks");
38
+ }