@jopqior/pi-subagents 1.0.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 (110) hide show
  1. package/CHANGELOG.md +2705 -0
  2. package/LICENSE +21 -0
  3. package/README.md +503 -0
  4. package/dist/public.d.ts +331 -0
  5. package/dist/settings.d.ts +82 -0
  6. package/docs/architecture/architecture.md +1566 -0
  7. package/docs/architecture/client-server-opportunities.md +127 -0
  8. package/docs/architecture/history/phase-1-api-boundary.md +8 -0
  9. package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
  10. package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
  11. package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
  12. package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
  13. package/docs/architecture/history/phase-14-strip-policy.md +49 -0
  14. package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
  15. package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
  16. package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
  17. package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
  18. package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
  19. package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
  20. package/docs/architecture/history/phase-20-result-delivery.md +245 -0
  21. package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
  22. package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
  23. package/docs/architecture/history/phase-4-implement-service.md +8 -0
  24. package/docs/architecture/history/phase-5-decompose-index.md +42 -0
  25. package/docs/architecture/history/phase-7-encapsulation.md +173 -0
  26. package/docs/architecture/history/phase-8-testability.md +103 -0
  27. package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
  28. package/docs/comparison-with-upstream.md +77 -0
  29. package/docs/configuration.md +364 -0
  30. package/docs/decisions/0001-deferred-patches.md +80 -0
  31. package/docs/decisions/0002-extensions-on-a-minimal-core.md +125 -0
  32. package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
  33. package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
  34. package/docs/decisions/0005-subagent-record-admission-policy.md +106 -0
  35. package/docs/decisions/0006-inherited-prompt-is-identity-only.md +104 -0
  36. package/docs/decisions/0007-transcript-viewer-is-not-an-overlay.md +228 -0
  37. package/docs/decisions/0008-inherited-region-is-shared-parts.md +81 -0
  38. package/docs/decisions/0009-portable-inheritance-is-provider-scoped.md +116 -0
  39. package/package.json +91 -0
  40. package/src/config/agent-types.ts +135 -0
  41. package/src/config/custom-agents.ts +151 -0
  42. package/src/config/default-agents.ts +121 -0
  43. package/src/config/invocation-config.ts +167 -0
  44. package/src/config/thinking-level.ts +58 -0
  45. package/src/debug.ts +14 -0
  46. package/src/handlers/index.ts +3 -0
  47. package/src/handlers/interrupt.ts +58 -0
  48. package/src/handlers/lifecycle.ts +71 -0
  49. package/src/handlers/widget-events.ts +49 -0
  50. package/src/index.ts +292 -0
  51. package/src/layered-settings.ts +105 -0
  52. package/src/lifecycle/child-lifecycle.ts +115 -0
  53. package/src/lifecycle/child-shutdown.ts +105 -0
  54. package/src/lifecycle/concurrency-limiter.ts +55 -0
  55. package/src/lifecycle/create-subagent-session.ts +335 -0
  56. package/src/lifecycle/parent-snapshot.ts +119 -0
  57. package/src/lifecycle/run-listeners.ts +37 -0
  58. package/src/lifecycle/selection-scope.ts +116 -0
  59. package/src/lifecycle/spawn-selection.ts +259 -0
  60. package/src/lifecycle/subagent-manager.ts +546 -0
  61. package/src/lifecycle/subagent-session.ts +347 -0
  62. package/src/lifecycle/subagent-state.ts +404 -0
  63. package/src/lifecycle/subagent.ts +885 -0
  64. package/src/lifecycle/turn-limits.ts +13 -0
  65. package/src/lifecycle/usage.ts +60 -0
  66. package/src/lifecycle/workspace-bracket.ts +76 -0
  67. package/src/lifecycle/workspace.ts +46 -0
  68. package/src/observation/composite-subagent-observer.ts +74 -0
  69. package/src/observation/notification.ts +430 -0
  70. package/src/observation/outcome-delivery.ts +239 -0
  71. package/src/observation/record-observer.ts +78 -0
  72. package/src/observation/renderer.ts +161 -0
  73. package/src/observation/subagent-events-observer.ts +148 -0
  74. package/src/runtime.ts +137 -0
  75. package/src/service/service-adapter.ts +201 -0
  76. package/src/service/service.ts +246 -0
  77. package/src/session/ask-parent-tool.ts +69 -0
  78. package/src/session/content-items.ts +53 -0
  79. package/src/session/context.ts +80 -0
  80. package/src/session/conversation.ts +49 -0
  81. package/src/session/env.ts +40 -0
  82. package/src/session/model-resolver.ts +126 -0
  83. package/src/session/notify-parent-tool.ts +83 -0
  84. package/src/session/package-exclusions.ts +75 -0
  85. package/src/session/prompts.ts +231 -0
  86. package/src/session/provider-inheritance.ts +56 -0
  87. package/src/session/selection-catalogue.ts +143 -0
  88. package/src/session/session-config.ts +202 -0
  89. package/src/session/session-dir.ts +38 -0
  90. package/src/settings.ts +447 -0
  91. package/src/tools/agent-tool.ts +305 -0
  92. package/src/tools/background-spawner.ts +83 -0
  93. package/src/tools/foreground-runner.ts +159 -0
  94. package/src/tools/get-result-renderer.ts +119 -0
  95. package/src/tools/get-result-report.ts +84 -0
  96. package/src/tools/get-result-tool.ts +192 -0
  97. package/src/tools/helpers.ts +118 -0
  98. package/src/tools/result-renderer.ts +153 -0
  99. package/src/tools/spawn-config.ts +192 -0
  100. package/src/tools/steer-tool.ts +109 -0
  101. package/src/types.ts +143 -0
  102. package/src/ui/agent-widget.ts +333 -0
  103. package/src/ui/bounded-lines.ts +45 -0
  104. package/src/ui/display.ts +180 -0
  105. package/src/ui/glyphs.ts +62 -0
  106. package/src/ui/session-navigation.ts +150 -0
  107. package/src/ui/session-navigator.ts +255 -0
  108. package/src/ui/subagents-settings.ts +179 -0
  109. package/src/ui/transcript-content.ts +374 -0
  110. package/src/ui/widget-renderer.ts +301 -0
@@ -0,0 +1,49 @@
1
+ /**
2
+ * conversation.ts — Render a subagent session's messages as formatted text.
3
+ *
4
+ * Extracted from agent-runner.ts (issue #265) into the session domain, where the
5
+ * other message-extraction helpers (content-items, context) live. Consumed by
6
+ * the get_subagent_result tool's verbose output.
7
+ */
8
+
9
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
10
+ import { extractAssistantContent } from "#src/session/content-items";
11
+ import { extractText } from "#src/session/context";
12
+
13
+ /**
14
+ * Get the subagent's conversation messages as formatted text.
15
+ */
16
+ export function getAgentConversation(session: AgentSession): string {
17
+ const parts: string[] = [];
18
+
19
+ for (const msg of session.messages) {
20
+ if (msg.role === "user") {
21
+ const text =
22
+ typeof msg.content === "string"
23
+ ? msg.content
24
+ : extractText(msg.content);
25
+ if (text.trim()) parts.push(`[User]: ${text.trim()}`);
26
+ } else if (msg.role === "assistant") {
27
+ const { textParts, toolNames } = extractAssistantContent(msg.content);
28
+ const attribution = formatAttribution(msg);
29
+ if (textParts.length > 0)
30
+ parts.push(`[Assistant${attribution}]: ${textParts.join("\n")}`);
31
+ if (toolNames.length > 0)
32
+ parts.push(`[Tool Calls]:\n${toolNames.map((n) => ` Tool: ${n}`).join("\n")}`);
33
+ } else if (msg.role === "toolResult") {
34
+ const text = extractText(msg.content);
35
+ const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text;
36
+ parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`);
37
+ }
38
+ }
39
+
40
+ return parts.join("\n\n");
41
+ }
42
+
43
+ /** Build a `(provider/model)` attribution suffix for assistant messages. */
44
+ function formatAttribution(msg: { provider?: string; model?: string }): string {
45
+ const { provider, model } = msg;
46
+ if (!provider && !model) return "";
47
+ if (provider && model) return ` (${provider}/${model})`;
48
+ return ` (${provider ?? model})`;
49
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * env.ts — Detect environment info (git, platform) for subagent system prompts.
3
+ */
4
+
5
+ import { debugLog } from "#src/debug";
6
+ import type { ShellExec } from "#src/types";
7
+
8
+ export interface EnvInfo {
9
+ isGitRepo: boolean;
10
+ branch: string;
11
+ platform: string;
12
+ }
13
+
14
+ export async function detectEnv(exec: ShellExec, cwd: string): Promise<EnvInfo> {
15
+ let isGitRepo = false;
16
+ let branch = "";
17
+
18
+ try {
19
+ const result = await exec("git", ["rev-parse", "--is-inside-work-tree"], { cwd, timeout: 5000 });
20
+ isGitRepo = result.code === 0 && result.stdout.trim() === "true";
21
+ } catch (err) {
22
+ debugLog("git rev-parse", err);
23
+ }
24
+
25
+ if (isGitRepo) {
26
+ try {
27
+ const result = await exec("git", ["branch", "--show-current"], { cwd, timeout: 5000 });
28
+ branch = result.code === 0 ? result.stdout.trim() : "unknown";
29
+ } catch (err) {
30
+ debugLog("git branch", err);
31
+ branch = "unknown";
32
+ }
33
+ }
34
+
35
+ return {
36
+ isGitRepo,
37
+ branch,
38
+ platform: process.platform,
39
+ };
40
+ }
@@ -0,0 +1,126 @@
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 model resolution when the model was user-specified (params) — surface the error. */
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 three-branch fallback policy used in `Agent.execute`:
31
+ * 1. No `modelInput` → inherit `parentModel`.
32
+ * 2. `modelInput` resolves → return the resolved model.
33
+ * 3. `modelInput` fails:
34
+ * - `modelFromParams` true → return `{ error }` so the caller can surface it.
35
+ * - `modelFromParams` false → silent fallback to `parentModel`.
36
+ *
37
+ * `modelFromParams` reports which side supplied the winning string, not merely whether
38
+ * the caller passed one — a caller whose value an agent's `locked:` frontmatter discarded
39
+ * did not win, so its typo is not the string being resolved here.
40
+ */
41
+ export function resolveInvocationModel(
42
+ parentModel: Model<any> | undefined,
43
+ modelInput: string | undefined,
44
+ modelFromParams: boolean,
45
+ registry: ModelRegistry | undefined,
46
+ ): ModelResolution {
47
+ if (!modelInput) return { model: parentModel };
48
+ if (!registry) return { error: "No model registry available." };
49
+ const resolved = resolveModel(modelInput, registry);
50
+ if (typeof resolved !== "string") return { model: resolved };
51
+ if (modelFromParams) return { error: resolved };
52
+ return { model: parentModel };
53
+ }
54
+
55
+ /**
56
+ * Resolve a model string to a Model instance.
57
+ * Tries exact match first ("provider/modelId"), then fuzzy match against all available models.
58
+ * Returns the Model on success, or an error message string on failure.
59
+ */
60
+ export function resolveModel(
61
+ input: string,
62
+ registry: ModelRegistry,
63
+ ): Model<any> | string {
64
+ // Available models (those with auth configured)
65
+ const all = registry.getAvailable?.() ?? registry.getAll();
66
+ const availableSet = new Set(all.map(m => `${m.provider}/${m.id}`.toLowerCase()));
67
+
68
+ // 1. Exact match: "provider/modelId" — only if available (has auth)
69
+ const slashIdx = input.indexOf("/");
70
+ if (slashIdx !== -1) {
71
+ const provider = input.slice(0, slashIdx);
72
+ const modelId = input.slice(slashIdx + 1);
73
+ if (availableSet.has(input.toLowerCase())) {
74
+ const found = registry.find(provider, modelId);
75
+ if (found) return found;
76
+ }
77
+ }
78
+
79
+ // 2. Fuzzy match against available models
80
+ const bestMatch = findBestFuzzyMatch(all, input.toLowerCase());
81
+ if (bestMatch) {
82
+ const found = registry.find(bestMatch.provider, bestMatch.id);
83
+ if (found) return found;
84
+ }
85
+
86
+ // 3. No match — list available models
87
+ const modelList = all
88
+ .map(m => ` ${m.provider}/${m.id}`)
89
+ .sort()
90
+ .join("\n");
91
+ return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
92
+ }
93
+
94
+ /**
95
+ * Score each candidate model — prefer exact id match > id contains > name
96
+ * contains > provider+id contains — and return the best match at or above
97
+ * the acceptance threshold (20), or undefined if nothing scores high enough.
98
+ */
99
+ function findBestFuzzyMatch(all: Model<any>[], query: string): Model<any> | undefined {
100
+ let bestMatch: Model<any> | undefined;
101
+ let bestScore = 0;
102
+
103
+ for (const m of all) {
104
+ const id = m.id.toLowerCase();
105
+ const name = m.name.toLowerCase();
106
+ const full = `${m.provider}/${m.id}`.toLowerCase();
107
+
108
+ let score = 0;
109
+ if (id === query || full === query) {
110
+ score = 100; // exact
111
+ } else if (id.includes(query) || full.includes(query)) {
112
+ score = 60 + (query.length / id.length) * 30; // substring, prefer tighter matches
113
+ } else if (name.includes(query)) {
114
+ score = 40 + (query.length / name.length) * 20;
115
+ } else if (query.split(/[\s\-/]+/).every(part => id.includes(part) || name.includes(part) || m.provider.toLowerCase().includes(part))) {
116
+ score = 20; // all parts present somewhere
117
+ }
118
+
119
+ if (score > bestScore) {
120
+ bestScore = score;
121
+ bestMatch = m;
122
+ }
123
+ }
124
+
125
+ return bestScore >= 20 ? bestMatch : undefined;
126
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * notify-parent-tool.ts — The tool a child sends its delegating agent a one-way
3
+ * update with.
4
+ *
5
+ * A running child that discovers something material — a course change, a wrong
6
+ * premise, a scope problem — says so without abandoning its run. The call
7
+ * returns at once; the parent may reply by steering, which the child picks up
8
+ * at its next turn boundary.
9
+ *
10
+ * Supplied to every child, gated only on the `midRunUpdates` setting. Where
11
+ * the message lands is decided per call rather than per child: while a carrier
12
+ * holds this run's outcome it is blocked awaiting the child, so that carrier
13
+ * renders the update into its own return instead of an announcement arriving
14
+ * after it. A child whose parent is free is nudged as it goes.
15
+ *
16
+ * Lives in `session/` alongside `ask-parent-tool.ts`, for the same reason.
17
+ */
18
+
19
+ import { defineTool } from "@earendil-works/pi-coding-agent";
20
+ import { Type } from "@sinclair/typebox";
21
+
22
+ export const NOTIFY_PARENT_TOOL_NAME = "notify_parent";
23
+
24
+ /**
25
+ * The longest update the channel carries.
26
+ *
27
+ * Neither carrier can be pulled from again — unlike a result, there is no
28
+ * `get_subagent_result` that returns the untruncated text — so the cut is made
29
+ * here, where the child is told about it and can be briefer next time.
30
+ */
31
+ export const UPDATE_MESSAGE_MAX_LENGTH = 2000;
32
+
33
+ const RESULT_TEXT =
34
+ "Update sent to the delegating agent. Continue working; it may steer you if it wants to redirect.";
35
+
36
+ /** Announces a child's update to its parent. */
37
+ export type UpdateAnnouncer = (message: string) => void;
38
+
39
+ export class NotifyParentTool {
40
+ constructor(private readonly announce: UpdateAnnouncer) {}
41
+
42
+ execute(
43
+ _toolCallId: string,
44
+ params: { message: string },
45
+ _signal: AbortSignal,
46
+ _onUpdate: unknown,
47
+ _ctx: unknown,
48
+ ) {
49
+ const truncated = params.message.length > UPDATE_MESSAGE_MAX_LENGTH;
50
+ this.announce(truncated ? params.message.slice(0, UPDATE_MESSAGE_MAX_LENGTH) : params.message);
51
+ const note = truncated
52
+ ? ` Your message was truncated to ${UPDATE_MESSAGE_MAX_LENGTH} characters; keep the next one shorter.`
53
+ : "";
54
+ // `details` is required by the SDK's result type; this tool renders none.
55
+ return { content: [{ type: "text" as const, text: RESULT_TEXT + note }], details: undefined };
56
+ }
57
+
58
+ toToolDefinition() {
59
+ return defineTool({
60
+ name: NOTIFY_PARENT_TOOL_NAME,
61
+ label: "Notify Parent",
62
+ promptSnippet: "Send the delegating agent a one-way update without pausing.",
63
+ description:
64
+ "Send a one-way update to the agent that delegated this task and keep working. Use it only " +
65
+ "for a material finding that changes what the delegating agent would do — a course change, a " +
66
+ "wrong premise, a scope problem — not for routine progress. It does not wait for a reply; the " +
67
+ "delegating agent may steer you if it wants to redirect.",
68
+ parameters: Type.Object({
69
+ message: Type.String({
70
+ description: "The finding the delegating agent should know about now rather than at the end.",
71
+ }),
72
+ }),
73
+ // The tool's own work is synchronous; the SDK's execute contract is not.
74
+ execute: (
75
+ toolCallId: string,
76
+ params: { message: string },
77
+ signal: AbortSignal,
78
+ onUpdate: unknown,
79
+ ctx: unknown,
80
+ ) => Promise.resolve(this.execute(toolCallId, params, signal, onUpdate, ctx)),
81
+ });
82
+ }
83
+ }
@@ -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,231 @@
1
+ /**
2
+ * prompts.ts — System prompt builder for agents.
3
+ */
4
+
5
+ import type { EnvInfo } from "#src/session/env";
6
+ import type { AgentPromptConfig, PromptInheritance } 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
+ * Which of the parent's contributions the child adopts as its identity.
16
+ * Absent means `"full"`, the strategy every provider gets unless its
17
+ * operator has said otherwise.
18
+ */
19
+ strategy?: PromptInheritance;
20
+ /**
21
+ * The parent's operator-authored parts, for a `"portable"` child. May be
22
+ * absent even then — the parent may have assembled no prompt yet, or have no
23
+ * such parts.
24
+ */
25
+ portablePrompt?: string;
26
+ }
27
+
28
+ /**
29
+ * Build the system prompt for an agent from its config.
30
+ *
31
+ * Both modes place the shared/stable parent prompt (or `genericBase` when no
32
+ * parent is available) first, so the inherited identity is a leading prefix the
33
+ * child shares with its parent across all subagent invocations. What that is
34
+ * worth is host-dependent — see ADR 0008.
35
+ *
36
+ * - "replace" mode: parent/genericBase + active_agent tag + env header +
37
+ * config.systemPrompt. No `<agent_instructions>` wrapper — the custom
38
+ * prompt has full control and the final say.
39
+ * - "append" mode: parent/genericBase + active_agent tag + env header +
40
+ * config.systemPrompt (wrapped in `<agent_instructions>` when non-empty).
41
+ * - "append" with empty systemPrompt: pure parent clone.
42
+ *
43
+ * The two modes now differ only in the `<agent_instructions>` wrapper. The
44
+ * `<sub_agent_context>` bridge append mode used to carry was removed in #890:
45
+ * its tool bullets duplicated the `promptGuidelines` Pi's own tools contribute
46
+ * to every child's prompt, and it asserted them unconditionally — telling a
47
+ * read-only child to use `edit` and `write` when it has neither.
48
+ *
49
+ * Both modes include an `<active_agent name="${config.name}"/>` tag so
50
+ * downstream extensions (e.g. `@gotgenes/pi-permission-system`) can resolve
51
+ * per-agent policy inside the child session by parsing the system prompt.
52
+ * The tag follows the cacheable parent prefix in both modes.
53
+ *
54
+ * Only the parent prompt's identity is inherited — see `inheritedIdentity`.
55
+ *
56
+ * @param inherited The parent agent's effective system prompt and the cwd it names.
57
+ */
58
+ export function buildAgentPrompt(
59
+ config: AgentPromptConfig,
60
+ cwd: string,
61
+ env: EnvInfo,
62
+ inherited?: InheritedPrompt,
63
+ ): string {
64
+ const header = buildPromptHeader(config.name, cwd, env);
65
+
66
+ const identity = inherited ? adoptedIdentity(inherited) : genericBase;
67
+
68
+ if (config.promptMode === "append") {
69
+ const customSection = config.systemPrompt.trim()
70
+ ? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
71
+ : "";
72
+
73
+ // Place the inherited identity first so it forms a shared leading prefix
74
+ // with the parent session, which prefix-reusing inference engines reuse
75
+ // instead of reprocessing. The <active_agent> tag and env block vary per
76
+ // call and are placed after that prefix.
77
+ return identity + "\n\n" + header + customSection;
78
+ }
79
+
80
+ // "replace" mode — identity prefix first, then the active_agent tag, env
81
+ // block, and the config's full system prompt. Unlike append mode, no
82
+ // <agent_instructions> wrapper is injected — the custom prompt retains full
83
+ // control.
84
+ return identity + "\n\n" + header + "\n\n" + config.systemPrompt;
85
+ }
86
+
87
+ /**
88
+ * The parent contribution the child adopts, per the strategy its provider set.
89
+ *
90
+ * `full` takes the assembled prompt's identity region, which stays a leading
91
+ * prefix shared with the parent (ADR 0008). `portable` takes the parent's
92
+ * operator-authored parts instead, for a provider that re-homes the prompt into
93
+ * a harness supplying its own base (ADR 0009).
94
+ *
95
+ * An absent or whitespace-only portable capture falls back to the generic base,
96
+ * never to the full prompt: opting into portable must never silently re-embed
97
+ * the harness base it exists to avoid.
98
+ */
99
+ function adoptedIdentity(inherited: InheritedPrompt): string {
100
+ if (inherited.strategy !== "portable") {
101
+ return inheritedIdentity(inherited.systemPrompt, inherited.cwd);
102
+ }
103
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || intentional: a whitespace-only capture must fall back too, which ?? would not do
104
+ return inherited.portablePrompt?.trim() || genericBase;
105
+ }
106
+
107
+ /**
108
+ * The per-call header both prompt modes share: the `<active_agent>` tag and the
109
+ * environment block. Both vary per invocation, so both sit after the cacheable
110
+ * identity prefix — and both modes need any content added here, which is why it
111
+ * has one home rather than being composed at each `return`.
112
+ */
113
+ function buildPromptHeader(agentName: string, cwd: string, env: EnvInfo): string {
114
+ const activeAgentTag = `<active_agent name="${agentName}"/>\n\n`;
115
+
116
+ const envBlock = `# Environment
117
+ Working directory: ${cwd}
118
+ ${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"}
119
+ Platform: ${env.platform}`;
120
+
121
+ return `${activeAgentTag}${envBlock}`;
122
+ }
123
+
124
+ /** First line of the section Pi writes above the `<available_skills>` catalogue. */
125
+ const SKILLS_SECTION_HEADING =
126
+ "The following skills provide specialized instructions for specific tasks.";
127
+
128
+ /** Closing tag of that catalogue. */
129
+ const SKILLS_CATALOGUE_CLOSE = "</available_skills>";
130
+
131
+ /**
132
+ * Reduce an inherited prompt to the identity a child may adopt as its own.
133
+ *
134
+ * Pi's `buildSystemPrompt` ends every prompt with layers it resolves per
135
+ * session — the `<available_skills>` catalogue, then a
136
+ * `Current working directory:` footer — and extensions append further blocks
137
+ * after those from `before_agent_start`, rebuilt from the base prompt on every
138
+ * turn. The child's own session rebuilds all of it against the child's
139
+ * directory, tool set, and extensions, so an inherited copy is a second, stale
140
+ * claim of each: a catalogue naming skills the child may not have (#801), and
141
+ * a footer that walks a workspace-isolated child back into the parent's
142
+ * directory (#640).
143
+ *
144
+ * Everything from the first such layer onward is therefore dropped. What
145
+ * precedes it is returned byte for byte, so it stays a shared prefix with the
146
+ * parent's prompt for hosts that reuse one over the system text (#180, #400).
147
+ * That is why no extension may edit the region in place: `Available tools:`
148
+ * sits a few hundred characters into it, and narrowing it there ended the
149
+ * shared prefix for every child with a narrowed tool set (#890).
150
+ *
151
+ * A prompt carrying neither layer is not one `buildSystemPrompt` assembled, and
152
+ * is returned unchanged.
153
+ */
154
+ function inheritedIdentity(prompt: string, parentCwd: string): string {
155
+ const lines = prompt.split("\n");
156
+ const tailStart = sessionResolvedTailStart(lines, parentCwd);
157
+ return tailStart === -1
158
+ ? prompt
159
+ : lines.slice(0, tailStart).join("\n").trimEnd();
160
+ }
161
+
162
+ /**
163
+ * Line index at which Pi's per-session layers begin, or -1 when none is present.
164
+ *
165
+ * The catalogue precedes the footer, so cutting at the catalogue already
166
+ * removes it; the footer is the anchor only for a parent session that resolved
167
+ * no skills. Matching whole lines makes the footer match exact, so a footer
168
+ * naming a directory that merely shares a prefix with the parent's is not
169
+ * mistaken for it, and it mirrors the separator normalization
170
+ * `buildSystemPrompt` applies.
171
+ */
172
+ function sessionResolvedTailStart(
173
+ lines: readonly string[],
174
+ parentCwd: string,
175
+ ): number {
176
+ const footerAt = lines.lastIndexOf(
177
+ `Current working directory: ${toPromptPath(parentCwd)}`,
178
+ );
179
+ const catalogueAt = skillsSectionStart(lines, footerAt);
180
+ return catalogueAt === -1 ? footerAt : catalogueAt;
181
+ }
182
+
183
+ /**
184
+ * Line index of the skills section's heading, or -1 when the section is absent.
185
+ *
186
+ * The heading is located by searching back from the catalogue's closing tag, so
187
+ * prose quoting Pi's heading ahead of the section is not mistaken for it.
188
+ */
189
+ function skillsSectionStart(
190
+ lines: readonly string[],
191
+ footerAt: number,
192
+ ): number {
193
+ const catalogueEnd = catalogueCloseBefore(lines, footerAt);
194
+ return catalogueEnd === -1
195
+ ? -1
196
+ : lines.lastIndexOf(SKILLS_SECTION_HEADING, catalogueEnd);
197
+ }
198
+
199
+ /**
200
+ * Line index of Pi's own catalogue closing tag, or -1 when it wrote none.
201
+ *
202
+ * `buildSystemPrompt` writes the cwd footer immediately after the catalogue, in
203
+ * both of its branches and unconditionally, so the tag on the line before the
204
+ * footer is Pi's own. Identifying it by that position rather than by document
205
+ * order keeps a catalogue quoted elsewhere — in a project-context file, or in a
206
+ * block an extension appended after the footer — from being taken for the
207
+ * section, in either direction.
208
+ *
209
+ * Without a footer to anchor on, something downstream has rewritten Pi's
210
+ * output; the last closing tag is the best remaining guess.
211
+ */
212
+ function catalogueCloseBefore(
213
+ lines: readonly string[],
214
+ footerAt: number,
215
+ ): number {
216
+ if (footerAt === -1) {
217
+ return lines.lastIndexOf(SKILLS_CATALOGUE_CLOSE);
218
+ }
219
+ return lines[footerAt - 1] === SKILLS_CATALOGUE_CLOSE ? footerAt - 1 : -1;
220
+ }
221
+
222
+ /** Render a path the way `buildSystemPrompt` writes it into a prompt. */
223
+ function toPromptPath(cwd: string): string {
224
+ return cwd.replaceAll("\\", "/");
225
+ }
226
+
227
+ /** Fallback base prompt when parent system prompt is unavailable (both modes). */
228
+ const genericBase = `# Role
229
+ You are a general-purpose coding agent for complex, multi-step tasks.
230
+ You have full access to read, write, edit files, and execute commands.
231
+ Do what has been asked; nothing more, nothing less.`;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Provider inheritance — replay a parent session's runtime provider
3
+ * registrations onto a child session's model runtime.
4
+ *
5
+ * Pi builds a fresh ModelRuntime for every session that is not handed one, and
6
+ * runtime registrations (pi.registerProvider) live on that instance rather than
7
+ * in models.json or auth.json. A child therefore starts with none of them and
8
+ * cannot resolve the parent's provider, which surfaces as
9
+ * "No API key found for <provider>" (Refs #812).
10
+ *
11
+ * The collaborators are narrow structural contracts rather than the SDK's
12
+ * ModelRegistry: the concrete class carries a private field, which would force
13
+ * test doubles to cast or replicate internals. Provider and config values are
14
+ * pass-through only — this module never inspects them — so they stay generic,
15
+ * which also avoids naming SDK types the package entry does not export.
16
+ */
17
+
18
+ /** Enumerates the providers registered on a session at runtime. */
19
+ export interface RegisteredProviderSource<TNative, TConfig> {
20
+ getRegisteredProviderIds(): readonly string[];
21
+ getRegisteredNativeProvider(id: string): TNative | undefined;
22
+ getRegisteredProviderConfig(id: string): TConfig | undefined;
23
+ }
24
+
25
+ /**
26
+ * Accepts provider registrations on behalf of a session.
27
+ *
28
+ * The two forms are separate methods rather than the SDK's single overloaded
29
+ * `registerProvider`, so a plain `vi.fn()` can stand in for each without a cast.
30
+ */
31
+ export interface ProviderRegistrar<TNative, TConfig> {
32
+ registerNative(provider: TNative): void;
33
+ registerConfigured(id: string, config: TConfig): void;
34
+ }
35
+
36
+ /**
37
+ * Copy every runtime-registered provider from `source` onto `target`.
38
+ *
39
+ * Pi keeps native and configured registrations in disjoint maps — registering
40
+ * one form deletes the id from the other — so each id resolves to exactly one
41
+ * form. An id that resolves to neither is skipped rather than registered empty.
42
+ */
43
+ export function inheritRegisteredProviders<TNative, TConfig>(
44
+ source: RegisteredProviderSource<TNative, TConfig>,
45
+ target: ProviderRegistrar<TNative, TConfig>,
46
+ ): void {
47
+ for (const id of source.getRegisteredProviderIds()) {
48
+ const native = source.getRegisteredNativeProvider(id);
49
+ if (native !== undefined) {
50
+ target.registerNative(native);
51
+ continue;
52
+ }
53
+ const config = source.getRegisteredProviderConfig(id);
54
+ if (config !== undefined) target.registerConfigured(id, config);
55
+ }
56
+ }