@tt-a1i/openpi 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 (114) hide show
  1. package/README.md +643 -0
  2. package/SETUP.md +74 -0
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/openpi-package.png +0 -0
  5. package/assets/readme-hero-mobile.svg +72 -0
  6. package/assets/readme-hero.svg +118 -0
  7. package/assets/readme-runtime-mobile.svg +91 -0
  8. package/assets/readme-runtime.svg +111 -0
  9. package/extensions/ask-user/handoff.ts +205 -0
  10. package/extensions/ask-user/index.ts +1110 -0
  11. package/extensions/ask-user/limits.ts +89 -0
  12. package/extensions/ask-user/prompt.ts +76 -0
  13. package/extensions/background-terminals/index.ts +653 -0
  14. package/extensions/background-terminals/src/domain.ts +99 -0
  15. package/extensions/background-terminals/src/manager.ts +989 -0
  16. package/extensions/background-terminals/src/output.ts +84 -0
  17. package/extensions/background-terminals/src/prompt.ts +195 -0
  18. package/extensions/background-terminals/src/result-delivery.ts +43 -0
  19. package/extensions/background-terminals/src/runtime.ts +36 -0
  20. package/extensions/background-terminals/src/ui/output-view.ts +55 -0
  21. package/extensions/background-terminals/src/ui/ps.ts +642 -0
  22. package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
  23. package/extensions/background-terminals/src/watch.ts +192 -0
  24. package/extensions/context-pivot/index.ts +222 -0
  25. package/extensions/copy-all/index.ts +65 -0
  26. package/extensions/cron/index.ts +173 -0
  27. package/extensions/cron/schedule.ts +127 -0
  28. package/extensions/file-mutation-display/index.ts +105 -0
  29. package/extensions/file-mutation-display/render.ts +107 -0
  30. package/extensions/file-search/index.ts +515 -0
  31. package/extensions/file-search/src/args.ts +129 -0
  32. package/extensions/file-search/src/binaries.ts +419 -0
  33. package/extensions/file-search/src/output.ts +142 -0
  34. package/extensions/file-search/src/process.ts +309 -0
  35. package/extensions/file-search/src/prompt.ts +53 -0
  36. package/extensions/git-info/index.ts +272 -0
  37. package/extensions/git-info/src/changed-files-view.ts +414 -0
  38. package/extensions/git-info/src/process.ts +107 -0
  39. package/extensions/git-info/src/refresh-coordinator.ts +13 -0
  40. package/extensions/git-info/src/runtime.ts +28 -0
  41. package/extensions/goal/controller.ts +794 -0
  42. package/extensions/goal/index.ts +521 -0
  43. package/extensions/goal/prompts.ts +122 -0
  44. package/extensions/goal/state.ts +763 -0
  45. package/extensions/goal/ui.ts +158 -0
  46. package/extensions/model-info/index.ts +234 -0
  47. package/extensions/plan-mode/bash-policy.ts +313 -0
  48. package/extensions/plan-mode/index.ts +539 -0
  49. package/extensions/post-edit/index.ts +129 -0
  50. package/extensions/sessions/LICENSE.upstream +21 -0
  51. package/extensions/sessions/git-stats.ts +226 -0
  52. package/extensions/sessions/index.ts +1092 -0
  53. package/extensions/sessions/sessions.ts +385 -0
  54. package/extensions/setup/index.ts +408 -0
  55. package/extensions/shared/activity-status.ts +65 -0
  56. package/extensions/shared/below-editor-navigation.ts +343 -0
  57. package/extensions/shared/child-session.ts +352 -0
  58. package/extensions/shared/context-utilization.ts +47 -0
  59. package/extensions/shared/dashboard-state.ts +102 -0
  60. package/extensions/shared/plan-mode-state.ts +65 -0
  61. package/extensions/shared/setup-config.ts +971 -0
  62. package/extensions/shared/subagent-roles.ts +22 -0
  63. package/extensions/shared/terminal-text.ts +38 -0
  64. package/extensions/shared/tool-call-timeout.ts +104 -0
  65. package/extensions/shared/worktree.ts +526 -0
  66. package/extensions/subagents/index.ts +1225 -0
  67. package/extensions/subagents/navigation.ts +121 -0
  68. package/extensions/subagents/src/agent-types.ts +543 -0
  69. package/extensions/subagents/src/backend.ts +63 -0
  70. package/extensions/subagents/src/backends/pi.ts +493 -0
  71. package/extensions/subagents/src/backends/stub.ts +296 -0
  72. package/extensions/subagents/src/by-the-way.ts +21 -0
  73. package/extensions/subagents/src/domain.ts +271 -0
  74. package/extensions/subagents/src/format.ts +48 -0
  75. package/extensions/subagents/src/manager.ts +769 -0
  76. package/extensions/subagents/src/prompt.ts +190 -0
  77. package/extensions/subagents/src/result-delivery.ts +20 -0
  78. package/extensions/subagents/src/runtime.ts +51 -0
  79. package/extensions/subagents/src/ui/takeover.ts +615 -0
  80. package/extensions/subagents/src/ui/transcript.ts +293 -0
  81. package/extensions/subagents/src/ui/wait-result.ts +89 -0
  82. package/extensions/suggestions/index.ts +172 -0
  83. package/extensions/suggestions/src/config.ts +12 -0
  84. package/extensions/suggestions/src/predictor.ts +147 -0
  85. package/extensions/suggestions/src/prompt.ts +20 -0
  86. package/extensions/suggestions/src/transcript.ts +233 -0
  87. package/extensions/suggestions/src/ui.ts +224 -0
  88. package/extensions/tasks/index.ts +512 -0
  89. package/extensions/tasks/tasks.ts +649 -0
  90. package/extensions/tasks/ui.ts +421 -0
  91. package/extensions/turn-time/index.ts +61 -0
  92. package/extensions/ui-customization/footer.ts +512 -0
  93. package/extensions/ui-customization/index.ts +217 -0
  94. package/extensions/workflows/acceptance.ts +298 -0
  95. package/extensions/workflows/artifacts.ts +225 -0
  96. package/extensions/workflows/controller.ts +210 -0
  97. package/extensions/workflows/dashboard.ts +1226 -0
  98. package/extensions/workflows/index.ts +1884 -0
  99. package/extensions/workflows/journal.ts +188 -0
  100. package/extensions/workflows/meta.ts +250 -0
  101. package/extensions/workflows/model.ts +423 -0
  102. package/extensions/workflows/navigation.ts +93 -0
  103. package/extensions/workflows/prompt.ts +212 -0
  104. package/extensions/workflows/replay-safety.ts +577 -0
  105. package/extensions/workflows/runner.ts +786 -0
  106. package/extensions/workflows/sandbox-child.cjs +402 -0
  107. package/extensions/workflows/sandbox.ts +397 -0
  108. package/extensions/workflows/serialization.ts +162 -0
  109. package/extensions/workflows/worktree-handoff.ts +216 -0
  110. package/package.json +87 -0
  111. package/scripts/prepare-effect-tsgo.mjs +16 -0
  112. package/skills/background-terminals/SKILL.md +30 -0
  113. package/skills/subagents/SKILL.md +15 -0
  114. package/themes/github-dark-default.json +89 -0
@@ -0,0 +1,343 @@
1
+ import type {
2
+ AppKeybinding,
3
+ KeybindingsManager,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import type {
6
+ AutocompleteProvider,
7
+ EditorComponent,
8
+ Focusable,
9
+ } from "@earendil-works/pi-tui";
10
+ import {
11
+ Key,
12
+ matchesKey,
13
+ truncateToWidth,
14
+ visibleWidth,
15
+ } from "@earendil-works/pi-tui";
16
+
17
+ /** Shared focus bit used by an editor wrapper and its below-editor strip. */
18
+ export class BelowEditorStripState {
19
+ focused = false;
20
+ }
21
+
22
+ export type BelowEditorStripInputAction = "focus" | "blur" | "open" | "next";
23
+
24
+ /**
25
+ * Interpret the navigation state between the editor and one management strip.
26
+ * Unrecognized input blurs the strip and falls through to the editor, so the
27
+ * interaction never traps normal typing.
28
+ */
29
+ export function belowEditorStripInput(options: {
30
+ data: string;
31
+ focused: boolean;
32
+ available: boolean;
33
+ editorEmpty: boolean;
34
+ }): BelowEditorStripInputAction | undefined {
35
+ const { data, focused, available, editorEmpty } = options;
36
+ if (!focused) {
37
+ return available && editorEmpty && matchesKey(data, Key.down)
38
+ ? "focus"
39
+ : undefined;
40
+ }
41
+ if (
42
+ matchesKey(data, Key.up) ||
43
+ matchesKey(data, Key.left) ||
44
+ matchesKey(data, Key.escape)
45
+ ) {
46
+ return "blur";
47
+ }
48
+ if (matchesKey(data, Key.enter) || matchesKey(data, Key.right)) {
49
+ return "open";
50
+ }
51
+ if (matchesKey(data, Key.down)) return "next";
52
+ return undefined;
53
+ }
54
+
55
+ interface AppAwareEditor extends EditorComponent {
56
+ actionHandlers: Map<AppKeybinding, () => void>;
57
+ onEscape?: () => void;
58
+ onCtrlD?: () => void;
59
+ onPasteImage?: () => void;
60
+ onExtensionShortcut?: (data: string) => boolean;
61
+ }
62
+
63
+ function appAwareEditor(editor: EditorComponent): AppAwareEditor | undefined {
64
+ const candidate = editor as EditorComponent & Partial<AppAwareEditor>;
65
+ return candidate.actionHandlers instanceof Map
66
+ ? (candidate as AppAwareEditor)
67
+ : undefined;
68
+ }
69
+
70
+ /**
71
+ * Editor wrapper that owns keyboard focus while one below-editor strip is
72
+ * selected. Wrappers compose: Down on a focused outer strip advances to the
73
+ * next available nested strip, while Down on the final strip stays consumed.
74
+ */
75
+ export class BelowEditorNavigationEditor implements EditorComponent, Focusable {
76
+ private readonly fallbackActions = new Map<AppKeybinding, () => void>();
77
+ private readonly base: EditorComponent;
78
+ private readonly keybindings: KeybindingsManager;
79
+ private readonly strip: BelowEditorStripState;
80
+ private readonly canManage: () => boolean;
81
+ private readonly open: () => void;
82
+ private readonly requestRender: () => void;
83
+ private fallbackEscape?: () => void;
84
+ private fallbackCtrlD?: () => void;
85
+ private fallbackPasteImage?: () => void;
86
+ private fallbackExtensionShortcut?: (data: string) => boolean;
87
+ private _focused = false;
88
+
89
+ constructor(
90
+ base: EditorComponent,
91
+ keybindings: KeybindingsManager,
92
+ strip: BelowEditorStripState,
93
+ canManage: () => boolean,
94
+ open: () => void,
95
+ requestRender: () => void,
96
+ ) {
97
+ this.base = base;
98
+ this.keybindings = keybindings;
99
+ this.strip = strip;
100
+ this.canManage = canManage;
101
+ this.open = open;
102
+ this.requestRender = requestRender;
103
+ }
104
+
105
+ get focused() {
106
+ return this._focused;
107
+ }
108
+
109
+ set focused(value: boolean) {
110
+ this._focused = value;
111
+ const child = this.base as EditorComponent & Partial<Focusable>;
112
+ if (typeof child.focused === "boolean") child.focused = value;
113
+ }
114
+
115
+ get wantsKeyRelease() {
116
+ return this.base.wantsKeyRelease;
117
+ }
118
+
119
+ // Pi recognizes these properties as CustomEditor-compatible and copies all
120
+ // app-level handlers onto the outermost wrapper.
121
+ get actionHandlers() {
122
+ return appAwareEditor(this.base)?.actionHandlers ?? this.fallbackActions;
123
+ }
124
+
125
+ get onExtensionShortcut() {
126
+ return (
127
+ appAwareEditor(this.base)?.onExtensionShortcut ??
128
+ this.fallbackExtensionShortcut
129
+ );
130
+ }
131
+
132
+ set onExtensionShortcut(value: ((data: string) => boolean) | undefined) {
133
+ const child = appAwareEditor(this.base);
134
+ if (child) child.onExtensionShortcut = value;
135
+ else this.fallbackExtensionShortcut = value;
136
+ }
137
+
138
+ get onEscape() {
139
+ return appAwareEditor(this.base)?.onEscape ?? this.fallbackEscape;
140
+ }
141
+
142
+ set onEscape(value: (() => void) | undefined) {
143
+ const child = appAwareEditor(this.base);
144
+ if (child) child.onEscape = value;
145
+ else this.fallbackEscape = value;
146
+ }
147
+
148
+ get onCtrlD() {
149
+ return appAwareEditor(this.base)?.onCtrlD ?? this.fallbackCtrlD;
150
+ }
151
+
152
+ set onCtrlD(value: (() => void) | undefined) {
153
+ const child = appAwareEditor(this.base);
154
+ if (child) child.onCtrlD = value;
155
+ else this.fallbackCtrlD = value;
156
+ }
157
+
158
+ get onPasteImage() {
159
+ return appAwareEditor(this.base)?.onPasteImage ?? this.fallbackPasteImage;
160
+ }
161
+
162
+ set onPasteImage(value: (() => void) | undefined) {
163
+ const child = appAwareEditor(this.base);
164
+ if (child) child.onPasteImage = value;
165
+ else this.fallbackPasteImage = value;
166
+ }
167
+
168
+ get onSubmit() {
169
+ return this.base.onSubmit;
170
+ }
171
+
172
+ set onSubmit(value: ((text: string) => void) | undefined) {
173
+ this.base.onSubmit = value;
174
+ }
175
+
176
+ get onChange() {
177
+ return this.base.onChange;
178
+ }
179
+
180
+ set onChange(value: ((text: string) => void) | undefined) {
181
+ this.base.onChange = value;
182
+ }
183
+
184
+ get borderColor() {
185
+ return this.base.borderColor;
186
+ }
187
+
188
+ set borderColor(value: ((text: string) => string) | undefined) {
189
+ this.base.borderColor = value;
190
+ }
191
+
192
+ hasFocusedStrip(): boolean {
193
+ return (
194
+ this.strip.focused ||
195
+ (this.nestedNavigation()?.hasFocusedStrip() ?? false)
196
+ );
197
+ }
198
+
199
+ blurAllStrips() {
200
+ this.strip.focused = false;
201
+ this.nestedNavigation()?.blurAllStrips();
202
+ }
203
+
204
+ /** Focus this strip, or the first available nested strip when it is hidden. */
205
+ focusAvailableStrip(): boolean {
206
+ if (this.canManage()) {
207
+ this.strip.focused = true;
208
+ return true;
209
+ }
210
+ return this.nestedNavigation()?.focusAvailableStrip() ?? false;
211
+ }
212
+
213
+ private nestedNavigation() {
214
+ return this.base instanceof BelowEditorNavigationEditor
215
+ ? this.base
216
+ : undefined;
217
+ }
218
+
219
+ handleInput(data: string) {
220
+ const nested = this.nestedNavigation();
221
+ if (!this.strip.focused && nested?.hasFocusedStrip()) {
222
+ // Availability is dynamic: if an outer strip appears after a nested one
223
+ // gained focus, Down cycles to the newly visible peer instead of being
224
+ // consumed forever by the old focus owner.
225
+ if (this.canManage() && matchesKey(data, Key.down)) {
226
+ nested.blurAllStrips();
227
+ this.strip.focused = true;
228
+ this.requestRender();
229
+ return;
230
+ }
231
+ this.base.handleInput(data);
232
+ return;
233
+ }
234
+ const action = belowEditorStripInput({
235
+ data,
236
+ focused: this.strip.focused,
237
+ available: this.canManage(),
238
+ editorEmpty: this.base.getText().length === 0,
239
+ });
240
+ if (action) {
241
+ if (action === "focus") this.strip.focused = true;
242
+ if (action === "blur") this.strip.focused = false;
243
+ if (action === "open") {
244
+ this.strip.focused = false;
245
+ this.open();
246
+ }
247
+ if (action === "next") {
248
+ this.strip.focused = false;
249
+ if (!this.nestedNavigation()?.focusAvailableStrip()) {
250
+ this.strip.focused = true;
251
+ }
252
+ }
253
+ this.requestRender();
254
+ return;
255
+ }
256
+ if (this.strip.focused) {
257
+ this.strip.focused = false;
258
+ this.requestRender();
259
+ }
260
+
261
+ const child = appAwareEditor(this.base);
262
+ if (!child) {
263
+ if (this.onExtensionShortcut?.(data)) return;
264
+ if (this.keybindings.matches(data, "app.clipboard.pasteImage")) {
265
+ this.fallbackPasteImage?.();
266
+ return;
267
+ }
268
+ if (this.keybindings.matches(data, "app.interrupt")) {
269
+ this.fallbackEscape?.();
270
+ return;
271
+ }
272
+ if (
273
+ this.keybindings.matches(data, "app.exit") &&
274
+ this.base.getText().length === 0
275
+ ) {
276
+ this.fallbackCtrlD?.();
277
+ return;
278
+ }
279
+ for (const [binding, handler] of this.fallbackActions) {
280
+ if (!this.keybindings.matches(data, binding)) continue;
281
+ if (binding === "app.exit" && this.base.getText().length > 0) break;
282
+ handler();
283
+ return;
284
+ }
285
+ }
286
+ this.base.handleInput(data);
287
+ }
288
+
289
+ render(width: number) {
290
+ return this.base.render(width);
291
+ }
292
+
293
+ invalidate() {
294
+ this.base.invalidate();
295
+ }
296
+
297
+ getText() {
298
+ return this.base.getText();
299
+ }
300
+
301
+ getExpandedText() {
302
+ return this.base.getExpandedText?.() ?? this.base.getText();
303
+ }
304
+
305
+ setText(text: string) {
306
+ this.strip.focused = false;
307
+ this.base.setText(text);
308
+ }
309
+
310
+ addToHistory(text: string) {
311
+ this.base.addToHistory?.(text);
312
+ }
313
+
314
+ insertTextAtCursor(text: string) {
315
+ this.base.insertTextAtCursor?.(text);
316
+ }
317
+
318
+ setAutocompleteProvider(provider: AutocompleteProvider) {
319
+ this.base.setAutocompleteProvider?.(provider);
320
+ }
321
+
322
+ setPaddingX(padding: number) {
323
+ this.base.setPaddingX?.(padding);
324
+ }
325
+
326
+ setAutocompleteMaxVisible(maxVisible: number) {
327
+ this.base.setAutocompleteMaxVisible?.(maxVisible);
328
+ }
329
+ }
330
+
331
+ /** Fit a left label and right metrics into exactly one bounded terminal row. */
332
+ export function fitNavigationSides(left: string, right: string, width: number) {
333
+ const boundedRight = truncateToWidth(right, Math.max(0, width - 4), "…");
334
+ const rightWidth = visibleWidth(boundedRight);
335
+ const leftWidth = Math.max(0, width - rightWidth - 1);
336
+ const boundedLeft = truncateToWidth(left, leftWidth, "…");
337
+ const gap = Math.max(1, width - visibleWidth(boundedLeft) - rightWidth);
338
+ return truncateToWidth(
339
+ boundedLeft + " ".repeat(gap) + boundedRight,
340
+ width,
341
+ "",
342
+ );
343
+ }
@@ -0,0 +1,352 @@
1
+ import * as path from "node:path";
2
+ import {
3
+ DefaultResourceLoader,
4
+ getAgentDir,
5
+ ProjectTrustStore,
6
+ SettingsManager,
7
+ type AgentSession,
8
+ type SessionShutdownEvent,
9
+ } from "@earendil-works/pi-coding-agent";
10
+
11
+ export const CHILD_SHUTDOWN_TIMEOUT_MS = 5_000;
12
+
13
+ /**
14
+ * The only package tools a headless child may keep. Read-only discovery tools
15
+ * are safe for children; every other tool this package registers is parent-only
16
+ * because it orchestrates work, mutates shared runtime state, drives the parent
17
+ * UI, or asks the user. This is the allowlist half of a fail-closed boundary:
18
+ * anything registered by this package and not listed here MUST be excluded, and
19
+ * `child-session.test.ts` scans the extensions to enforce exactly that — so a
20
+ * future tool cannot silently leak into children by being forgotten. The scan
21
+ * covers both inline-named registrations and factory-style ones (e.g.
22
+ * `registerTool(createEditToolDefinition(...))`); an unrecognized factory
23
+ * registration fails the guard rather than escaping it.
24
+ */
25
+ export const CHILD_SAFE_PACKAGE_TOOL_NAMES = ["fd", "rg"] as const;
26
+
27
+ /**
28
+ * pi-intercom resources are unsafe inside concurrent in-process child sessions.
29
+ * It stores session identity and optional supervisor bridge metadata in
30
+ * process.env, which is shared by every Direct/Workflow child in this process.
31
+ * Loading it in children can therefore cross-wire identities; the parent
32
+ * extension remains loaded and fully usable.
33
+ */
34
+ function isPiIntercomNpmResource(resource: {
35
+ resolvedPath?: string;
36
+ filePath?: string;
37
+ }) {
38
+ const resourcePath = resource.resolvedPath ?? resource.filePath;
39
+ if (!resourcePath) return false;
40
+ const segments = path.resolve(resourcePath).split(path.sep);
41
+ return segments.some(
42
+ (segment, index) =>
43
+ segment === "node_modules" && segments[index + 1] === "pi-intercom",
44
+ );
45
+ }
46
+
47
+ /**
48
+ * Tools that headless children must not receive. Children run without a UI and
49
+ * cannot orchestrate, so every parent-only tool this package ships is denied.
50
+ * Grouped by owning extension; keep in sync with the extensions (guarded by the
51
+ * drift test in child-session.test.ts).
52
+ */
53
+ export const CHILD_EXCLUDED_TOOL_NAMES = [
54
+ // subagents — children cannot spawn/observe more agents
55
+ "subagent_spawn",
56
+ "subagent_wait",
57
+ "subagent_cancel",
58
+ "subagent_send",
59
+ "subagent_check",
60
+ "subagent_list",
61
+ // workflows — children cannot recursively orchestrate or manage runs
62
+ "workflow",
63
+ "workflow_stop",
64
+ "workflow_status",
65
+ // ask-user — headless children have no user to ask or handoff to
66
+ "ask_user",
67
+ "human_handoff",
68
+ // plan-mode — completion controls the parent session's write gate and UI
69
+ "plan_ready",
70
+ // tasks — the parent session owns its work-intent ledger
71
+ "tasks_add",
72
+ "tasks_update",
73
+ "tasks_list",
74
+ // goal — the parent session owns its persistent goal
75
+ "get_goal",
76
+ "create_goal",
77
+ "update_goal",
78
+ // setup — configuration is a package-owned, parent-only choice
79
+ "configure_my_pi_setup",
80
+ // background-terminals — a child's processes would be invisible to the
81
+ // parent's /ps and could outlive the child, so children cannot start them
82
+ "bg_start",
83
+ "bg_status",
84
+ "bg_list",
85
+ "bg_kill",
86
+ "bg_watch",
87
+ // context-pivot — compaction of the conversation is a parent-only decision
88
+ "context_pivot",
89
+ ] as const;
90
+
91
+ /**
92
+ * Fresh SDK options avoid turning the denylist into an accidental allowlist.
93
+ *
94
+ * `tools` narrows further, for an agent type that fixes a child's capabilities
95
+ * (see `../subagents/src/agent-types.ts`). It can only ever REMOVE: pi admits a
96
+ * tool when `(!allowed || allowed.has(name)) && !excluded.has(name)`, so an
97
+ * allowlist naming an excluded tool still cannot obtain it, and the boundary
98
+ * above stays authoritative. Omitting `tools` keeps today's behavior.
99
+ *
100
+ * Remove parent-only tools before exposing or enforcing a requested allowlist.
101
+ * Undefined preserves the normal child tool set; an empty array deliberately
102
+ * means no tools.
103
+ */
104
+ export function effectiveChildToolAllowlist(tools?: readonly string[]) {
105
+ return tools?.filter(
106
+ (tool) => !CHILD_EXCLUDED_TOOL_NAMES.includes(tool as never),
107
+ );
108
+ }
109
+
110
+ export function childToolPolicy(tools?: readonly string[]) {
111
+ const effectiveTools = effectiveChildToolAllowlist(tools);
112
+ return {
113
+ excludeTools: [...CHILD_EXCLUDED_TOOL_NAMES],
114
+ ...(effectiveTools ? { tools: effectiveTools } : {}),
115
+ };
116
+ }
117
+
118
+ export interface ChildResourceOptions {
119
+ cwd: string;
120
+ projectTrusted: boolean;
121
+ appendSystemPrompt?: string[];
122
+ agentDir?: string;
123
+ }
124
+
125
+ /** Load normal global/package resources and trust-gated project resources. */
126
+ export async function createChildResources(options: ChildResourceOptions) {
127
+ const agentDir = options.agentDir ?? getAgentDir();
128
+ const settingsManager = SettingsManager.create(options.cwd, agentDir, {
129
+ projectTrusted: options.projectTrusted,
130
+ });
131
+ const loader = new DefaultResourceLoader({
132
+ cwd: options.cwd,
133
+ agentDir,
134
+ settingsManager,
135
+ ...(options.appendSystemPrompt
136
+ ? { appendSystemPrompt: options.appendSystemPrompt }
137
+ : {}),
138
+ extensionsOverride: (base) => ({
139
+ ...base,
140
+ extensions: base.extensions.filter(
141
+ (extension) => !isPiIntercomNpmResource(extension),
142
+ ),
143
+ }),
144
+ skillsOverride: (base) => ({
145
+ ...base,
146
+ skills: base.skills.filter((skill) => !isPiIntercomNpmResource(skill)),
147
+ }),
148
+ });
149
+ await loader.reload();
150
+ return { loader, settingsManager };
151
+ }
152
+
153
+ /**
154
+ * Whether Pi's persisted trust store explicitly trusts this directory (or a
155
+ * containing one). Unreadable or invalid trust data fails closed.
156
+ *
157
+ * Use this when no live trust decision exists yet — notably at extension
158
+ * registration, which runs before `session_start`.
159
+ */
160
+ export function isProjectTrustedOnDisk(cwd: string, agentDir?: string) {
161
+ try {
162
+ const trustStore = new ProjectTrustStore(agentDir ?? getAgentDir());
163
+ return trustStore.get(cwd) === true;
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Same-directory children inherit the live parent decision. An alternate cwd
171
+ * is trusted only when Pi's persisted trust store explicitly trusts it (or a
172
+ * containing directory); unreadable/invalid trust data fails closed.
173
+ */
174
+ export function resolveStandaloneChildProjectTrust(options: {
175
+ parentCwd: string;
176
+ childCwd: string;
177
+ parentTrusted: boolean;
178
+ agentDir?: string;
179
+ }) {
180
+ if (path.resolve(options.childCwd) === path.resolve(options.parentCwd)) {
181
+ return options.parentTrusted;
182
+ }
183
+ return isProjectTrustedOnDisk(options.childCwd, options.agentDir);
184
+ }
185
+
186
+ interface ChildSessionStartup {
187
+ bindExtensions(bindings: { mode: "print" }): Promise<void>;
188
+ getActiveToolNames(): string[];
189
+ }
190
+
191
+ function boundedToolNames(names: readonly string[]) {
192
+ const shown = names
193
+ .slice(0, 8)
194
+ .map((name) => JSON.stringify(name.slice(0, 128)))
195
+ .join(", ");
196
+ return names.length > 8 ? `${shown}, and ${names.length - 8} more` : shown;
197
+ }
198
+
199
+ /**
200
+ * Start child extensions, then fail before the first prompt if an explicit
201
+ * allowlist names a tool that the final bound registry cannot actually expose.
202
+ * Parent-only names are deliberately ignored here: the denylist remains
203
+ * authoritative, so naming one can never turn this check into a grant.
204
+ */
205
+ export async function bindChildSessionExtensions(
206
+ session: ChildSessionStartup,
207
+ requestedTools?: readonly string[],
208
+ ) {
209
+ await session.bindExtensions({ mode: "print" });
210
+ const requested = effectiveChildToolAllowlist(requestedTools);
211
+ if (!requested) return;
212
+
213
+ const active = new Set(session.getActiveToolNames());
214
+ const missing = [...new Set(requested)].filter((name) => !active.has(name));
215
+ if (missing.length === 0) return;
216
+
217
+ throw new Error(
218
+ `Child tool preflight failed: requested tool${missing.length === 1 ? "" : "s"} ${boundedToolNames(missing)} ${missing.length === 1 ? "is" : "are"} unavailable after child extensions initialized. Check the Agent Type tools list and child extension loading.`,
219
+ );
220
+ }
221
+
222
+ interface ChildExtensionRunner {
223
+ hasHandlers(eventType: string): boolean;
224
+ emit(event: SessionShutdownEvent): Promise<unknown>;
225
+ }
226
+
227
+ export interface DisposableChildSession {
228
+ readonly extensionRunner: ChildExtensionRunner;
229
+ abort?(): Promise<unknown>;
230
+ dispose(): void;
231
+ }
232
+
233
+ export interface ChildShutdownResult {
234
+ ok: boolean;
235
+ errors: string[];
236
+ timedOut: boolean;
237
+ }
238
+
239
+ const childShutdowns = new WeakMap<object, Promise<ChildShutdownResult>>();
240
+
241
+ function shutdownError(error: unknown) {
242
+ return (error instanceof Error ? error.message : String(error)).slice(
243
+ 0,
244
+ 4096,
245
+ );
246
+ }
247
+
248
+ /** Await an operation but never longer than `timeoutMs`; return whether it settled. */
249
+ export async function waitBounded(
250
+ operation: Promise<unknown>,
251
+ timeoutMs: number,
252
+ ) {
253
+ const result = await waitUntil(
254
+ operation,
255
+ Date.now() + timeoutMs,
256
+ "Operation",
257
+ );
258
+ return !result.timedOut;
259
+ }
260
+
261
+ /** Await an operation until a shared deadline and retain failure information. */
262
+ async function waitUntil(
263
+ operation: Promise<unknown>,
264
+ deadline: number,
265
+ label: string,
266
+ ): Promise<{ error?: string; timedOut: boolean }> {
267
+ const remaining = Math.max(0, deadline - Date.now());
268
+ let timer: ReturnType<typeof setTimeout> | undefined;
269
+ const timeout = new Promise<{ error: string; timedOut: true }>((resolve) => {
270
+ timer = setTimeout(
271
+ () => resolve({ error: `${label} timed out`, timedOut: true }),
272
+ remaining,
273
+ );
274
+ timer.unref?.();
275
+ });
276
+ const completed = operation.then(
277
+ () => ({ timedOut: false as const }),
278
+ (error) => ({
279
+ error: `${label} failed: ${shutdownError(error)}`,
280
+ timedOut: false as const,
281
+ }),
282
+ );
283
+ const result = await Promise.race([completed, timeout]);
284
+ if (timer) clearTimeout(timer);
285
+ return result;
286
+ }
287
+
288
+ /**
289
+ * Request abort when needed, emit child session_shutdown once, and dispose
290
+ * once. The whole sequence shares one deadline and reports timeout/failure
291
+ * instead of silently turning failed cleanup into success.
292
+ */
293
+ export function shutdownAndDisposeChildSession(
294
+ session: DisposableChildSession,
295
+ options: {
296
+ timeoutMs?: number;
297
+ abort?: boolean;
298
+ abortOperation?: Promise<unknown>;
299
+ } = {},
300
+ ) {
301
+ const existing = childShutdowns.get(session);
302
+ if (existing) return existing;
303
+
304
+ const shutdown = (async (): Promise<ChildShutdownResult> => {
305
+ const errors: string[] = [];
306
+ let timedOut = false;
307
+ const deadline =
308
+ Date.now() + (options.timeoutMs ?? CHILD_SHUTDOWN_TIMEOUT_MS);
309
+
310
+ if (options.abort || options.abortOperation) {
311
+ let abortOperation = options.abortOperation;
312
+ try {
313
+ abortOperation ??= session.abort?.();
314
+ } catch (error) {
315
+ errors.push(`Agent abort failed: ${shutdownError(error)}`);
316
+ }
317
+ if (abortOperation) {
318
+ const result = await waitUntil(abortOperation, deadline, "Agent abort");
319
+ if (result.error) errors.push(result.error);
320
+ timedOut ||= result.timedOut;
321
+ }
322
+ }
323
+
324
+ try {
325
+ if (session.extensionRunner.hasHandlers("session_shutdown")) {
326
+ const result = await waitUntil(
327
+ session.extensionRunner.emit({
328
+ type: "session_shutdown",
329
+ reason: "quit",
330
+ }),
331
+ deadline,
332
+ "Child session shutdown",
333
+ );
334
+ if (result.error) errors.push(result.error);
335
+ timedOut ||= result.timedOut;
336
+ }
337
+ } catch (error) {
338
+ errors.push(`Child session shutdown failed: ${shutdownError(error)}`);
339
+ }
340
+
341
+ try {
342
+ session.dispose();
343
+ } catch (error) {
344
+ errors.push(`Child session disposal failed: ${shutdownError(error)}`);
345
+ }
346
+
347
+ return { ok: errors.length === 0, errors, timedOut };
348
+ })();
349
+
350
+ childShutdowns.set(session, shutdown);
351
+ return shutdown;
352
+ }