@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.
- package/README.md +643 -0
- package/SETUP.md +74 -0
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/openpi-package.png +0 -0
- package/assets/readme-hero-mobile.svg +72 -0
- package/assets/readme-hero.svg +118 -0
- package/assets/readme-runtime-mobile.svg +91 -0
- package/assets/readme-runtime.svg +111 -0
- package/extensions/ask-user/handoff.ts +205 -0
- package/extensions/ask-user/index.ts +1110 -0
- package/extensions/ask-user/limits.ts +89 -0
- package/extensions/ask-user/prompt.ts +76 -0
- package/extensions/background-terminals/index.ts +653 -0
- package/extensions/background-terminals/src/domain.ts +99 -0
- package/extensions/background-terminals/src/manager.ts +989 -0
- package/extensions/background-terminals/src/output.ts +84 -0
- package/extensions/background-terminals/src/prompt.ts +195 -0
- package/extensions/background-terminals/src/result-delivery.ts +43 -0
- package/extensions/background-terminals/src/runtime.ts +36 -0
- package/extensions/background-terminals/src/ui/output-view.ts +55 -0
- package/extensions/background-terminals/src/ui/ps.ts +642 -0
- package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
- package/extensions/background-terminals/src/watch.ts +192 -0
- package/extensions/context-pivot/index.ts +222 -0
- package/extensions/copy-all/index.ts +65 -0
- package/extensions/cron/index.ts +173 -0
- package/extensions/cron/schedule.ts +127 -0
- package/extensions/file-mutation-display/index.ts +105 -0
- package/extensions/file-mutation-display/render.ts +107 -0
- package/extensions/file-search/index.ts +515 -0
- package/extensions/file-search/src/args.ts +129 -0
- package/extensions/file-search/src/binaries.ts +419 -0
- package/extensions/file-search/src/output.ts +142 -0
- package/extensions/file-search/src/process.ts +309 -0
- package/extensions/file-search/src/prompt.ts +53 -0
- package/extensions/git-info/index.ts +272 -0
- package/extensions/git-info/src/changed-files-view.ts +414 -0
- package/extensions/git-info/src/process.ts +107 -0
- package/extensions/git-info/src/refresh-coordinator.ts +13 -0
- package/extensions/git-info/src/runtime.ts +28 -0
- package/extensions/goal/controller.ts +794 -0
- package/extensions/goal/index.ts +521 -0
- package/extensions/goal/prompts.ts +122 -0
- package/extensions/goal/state.ts +763 -0
- package/extensions/goal/ui.ts +158 -0
- package/extensions/model-info/index.ts +234 -0
- package/extensions/plan-mode/bash-policy.ts +313 -0
- package/extensions/plan-mode/index.ts +539 -0
- package/extensions/post-edit/index.ts +129 -0
- package/extensions/sessions/LICENSE.upstream +21 -0
- package/extensions/sessions/git-stats.ts +226 -0
- package/extensions/sessions/index.ts +1092 -0
- package/extensions/sessions/sessions.ts +385 -0
- package/extensions/setup/index.ts +408 -0
- package/extensions/shared/activity-status.ts +65 -0
- package/extensions/shared/below-editor-navigation.ts +343 -0
- package/extensions/shared/child-session.ts +352 -0
- package/extensions/shared/context-utilization.ts +47 -0
- package/extensions/shared/dashboard-state.ts +102 -0
- package/extensions/shared/plan-mode-state.ts +65 -0
- package/extensions/shared/setup-config.ts +971 -0
- package/extensions/shared/subagent-roles.ts +22 -0
- package/extensions/shared/terminal-text.ts +38 -0
- package/extensions/shared/tool-call-timeout.ts +104 -0
- package/extensions/shared/worktree.ts +526 -0
- package/extensions/subagents/index.ts +1225 -0
- package/extensions/subagents/navigation.ts +121 -0
- package/extensions/subagents/src/agent-types.ts +543 -0
- package/extensions/subagents/src/backend.ts +63 -0
- package/extensions/subagents/src/backends/pi.ts +493 -0
- package/extensions/subagents/src/backends/stub.ts +296 -0
- package/extensions/subagents/src/by-the-way.ts +21 -0
- package/extensions/subagents/src/domain.ts +271 -0
- package/extensions/subagents/src/format.ts +48 -0
- package/extensions/subagents/src/manager.ts +769 -0
- package/extensions/subagents/src/prompt.ts +190 -0
- package/extensions/subagents/src/result-delivery.ts +20 -0
- package/extensions/subagents/src/runtime.ts +51 -0
- package/extensions/subagents/src/ui/takeover.ts +615 -0
- package/extensions/subagents/src/ui/transcript.ts +293 -0
- package/extensions/subagents/src/ui/wait-result.ts +89 -0
- package/extensions/suggestions/index.ts +172 -0
- package/extensions/suggestions/src/config.ts +12 -0
- package/extensions/suggestions/src/predictor.ts +147 -0
- package/extensions/suggestions/src/prompt.ts +20 -0
- package/extensions/suggestions/src/transcript.ts +233 -0
- package/extensions/suggestions/src/ui.ts +224 -0
- package/extensions/tasks/index.ts +512 -0
- package/extensions/tasks/tasks.ts +649 -0
- package/extensions/tasks/ui.ts +421 -0
- package/extensions/turn-time/index.ts +61 -0
- package/extensions/ui-customization/footer.ts +512 -0
- package/extensions/ui-customization/index.ts +217 -0
- package/extensions/workflows/acceptance.ts +298 -0
- package/extensions/workflows/artifacts.ts +225 -0
- package/extensions/workflows/controller.ts +210 -0
- package/extensions/workflows/dashboard.ts +1226 -0
- package/extensions/workflows/index.ts +1884 -0
- package/extensions/workflows/journal.ts +188 -0
- package/extensions/workflows/meta.ts +250 -0
- package/extensions/workflows/model.ts +423 -0
- package/extensions/workflows/navigation.ts +93 -0
- package/extensions/workflows/prompt.ts +212 -0
- package/extensions/workflows/replay-safety.ts +577 -0
- package/extensions/workflows/runner.ts +786 -0
- package/extensions/workflows/sandbox-child.cjs +402 -0
- package/extensions/workflows/sandbox.ts +397 -0
- package/extensions/workflows/serialization.ts +162 -0
- package/extensions/workflows/worktree-handoff.ts +216 -0
- package/package.json +87 -0
- package/scripts/prepare-effect-tsgo.mjs +16 -0
- package/skills/background-terminals/SKILL.md +30 -0
- package/skills/subagents/SKILL.md +15 -0
- 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
|
+
}
|