@signalridge/pi-subagents 1.11.1 → 1.12.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/CHANGELOG.md +17 -0
- package/README.md +63 -7
- package/package.json +2 -2
- package/src/agent-color.ts +1 -1
- package/src/agent-manager.ts +27 -2
- package/src/agent-runner.ts +83 -35
- package/src/extension-resource-names.ts +105 -0
- package/src/index.ts +44 -2
- package/src/parent-questions.ts +453 -0
- package/src/question-origin.ts +24 -0
- package/src/supervisor.ts +28 -8
- package/src/ui/agent-display.ts +2 -1
- package/src/ui/conversation-viewer.ts +49 -18
- package/src/ui/fleet-list.ts +125 -58
- package/src/ui/owner-group.ts +102 -0
- package/src/ui/schedule-menu.ts +14 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.12.0
|
|
4
|
+
### Minor Changes
|
|
5
|
+
|
|
6
|
+
- c2fd867: - Add an independently implemented, explicitly opted-in background-only parent clarification broker. Main-only answer_subagent_question validates one-use bounded IDs, original child/session/branch/attempt ownership and offered choice tokens; raw choices stay separate from sanitized request-local display. Coalesce bounded pending questions into ordinary prepared idle/settled extension turns without cloning a model, rewriting user text/images, copying parent history, or adding question storage/settings. Cancellation/timeout/retirement returns structured no-answer, never approval. Default human contact, ask_tools and external permission gates remain unchanged. Reject unsupported foreground/scheduled/mode-changing resume requests before spawn and leave managed protocol-v4 payloads unchanged.
|
|
7
|
+
- Fence parent questions to captured public session-manager identity and complete native branch prefixes at both channel construction and each live question invocation, including resumed-child metadata divergence before delayed committed-tree notifications. Track observed root preparation separately from native isIdle and release package-owned handled mentions with INPUT-scoped readiness leases; native throws continue preparation and another concurrent prompt's lease remains intact. Authenticate factory-local extension wakes with bounded nonce/MAC tokens and constant-memory historical proof recognition. Separate accepted-frame provenance and public ctx.signal/native-end run ownership from the outbox flight: a competing preparation's busy-failure settlement cannot erase the executing frame. Distinct frame tokens and authenticated empty-content native metadata prove only unambiguous admitted frames at public message boundaries, without guessing from promptless agent_start or stripping verbatim user quotations. An INPUT-observed copy of an unprepared frame token retires that bounded admission as ambiguous: neither prompt receives proof, abort, question context, or text/image stripping; affected questions return no-answer, while later ordinary input contexts can receive new questions. Recognize the authenticated leading token boundary plus its retained ambiguous admission even after native SDK image hints are appended; suppress only that question channel without creating control ownership, aborting, or modifying user data. Request-local context filters proven control pairs and only the proven obsolete control run can be aborted. ACK delivery against the exact flight, bound each question to one wake attempt with 30-second unacknowledged no-answer recovery, and retain unresolved accepted preparations within a fixed admission cap. Document external handled/auth-rejection and ambiguous-preparation readiness limits, native concurrent-prompt busy rejection, and the absence of atomic pre-persistence cancellation; verify real SDK later-preparation double barriers, user/proof message-start/end awaits through busy settlements, copied-token release orders with/without images and real default-AUTO 4096x4096 PNG normalization while another child's question remains pending, guard-disabled controls, handled mentions, resumed-child summaries, and live ownership without private scheduler/renderer/history patches. Human permission gates and child answer-tool capability isolation remain unchanged.
|
|
8
|
+
- Exclude pi-background from child resource loading before factories execute, including default inherited loaders and wildcard selection. Refuse intentional extension selections by name and warn on filtered inheritance/tool selectors: delayed process exits must not wake a retained child after manager completion, concurrency-slot release, or worktree cleanup. Merge configured and explicit ownership by canonical real file identity, including configured generic file symlinks and direct glob/conventional entries resolved through Pi's public package manager. Canonical declaration probes remain additive across ancestor manifests and disabled resource preferences, so an ordinary lexical owner cannot conceal a background target. Retain package names and source aliases for loaded-name validation and live `ext:` tool narrowing. Ordinary extensions remain available and no background settings schema is duplicated.
|
|
9
|
+
- Selectively port tintinweb/pi-subagents commit 7da6bef828d7582ee33386ece9379c170e7742c4 (2026-10-09, #374; reported by @BarrMan): FleetView recognizes non-inheriting custom editors by callable getText/setText while preserving dialog/selector input ownership and unknown-focus fallback. Raw inherit_context remains refused; no clone-parent-history behavior is imported.
|
|
10
|
+
- Separate per-resource declaring ownership from admission/exclusion alias unions, probe directory-backed module entries with public Node resolution without factory evaluation, and validate explicit sources before cross-source deduplication. Existing file/package aliases execute once; selecting another package's canonical entry no longer suppresses an alias owner's unrelated entries.
|
|
11
|
+
- Add independently implemented container-first workflow-owner groups and a bounded retained-child inspector, inspired by tintinweb/pi-subagents 221df02323c6b6350ea8474579e0ee0f6cc84cea/fd8d81618d80ae83a3faf40cb87dae99cca33264 and collision-only discriminators from nicobailon/pi-subagents 69673c7fbf3351e54a5b0786a7e9d9fff625a749. Counts describe retained children only; no authoritative workflow status/name/phase inference, upstream engine import, or new RPC. Preserve upstream license attribution.
|
|
12
|
+
- Keep Fleet selection by stable ID across refresh/linger, retain mounting and public structural focus evidence during invalidation, and recover overlay ownership after opener errors/disposal. Bind child conversation controls to their retained session and owner attempt so thread replacement cannot receive stale commands.
|
|
13
|
+
- Preserve rejected steering drafts (including boolean/async false and thrown failures) with an error while retaining legacy void callbacks. Completed viewers use retained lifetime usage and known compaction statistics without unguarded disposed-session access. Encode scheduled-job display copies and UI labels with the shared pi-ui sanitizer without rewriting stored prompts/paths/messages.
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- Updated dependencies [c2fd867]
|
|
18
|
+
- @signalridge/pi-ui@1.4.0
|
|
19
|
+
|
|
3
20
|
## 1.11.1
|
|
4
21
|
### Patch Changes
|
|
5
22
|
|
package/README.md
CHANGED
|
@@ -46,7 +46,15 @@ From the repository root:
|
|
|
46
46
|
pi -e ./packages/pi-subagents
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
**Verified host baseline:** Pi 1.0.4. Wildcard peer dependency ranges and retained capability fallbacks do not establish support for older host versions.
|
|
49
|
+
**Verified host baseline:** Pi 1.0.4. Offline resource-ownership, child tool-policy, and native Fleet input barriers are additionally exercised on Pi 1.1.0; that focused coverage is not complete host qualification. Wildcard peer dependency ranges and retained capability fallbacks do not establish support for older host versions.
|
|
50
|
+
|
|
51
|
+
## Upstream and local divergence
|
|
52
|
+
|
|
53
|
+
Derived from [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents); the upstream MIT license is preserved. The FleetView editor-focus fix from [commit `7da6bef828d7582ee33386ece9379c170e7742c4`](https://github.com/tintinweb/pi-subagents/commit/7da6bef828d7582ee33386ece9379c170e7742c4) (2026-10-09, #374; reported by @BarrMan) is selectively ported: callable `getText`/`setText` identifies non-inheriting custom editors without taking dialog or selector keys. No clone-parent-history behavior is imported; raw `inherit_context: true` remains refused. This fork also excludes `pi-background` from child resource loading until asynchronous child continuation has manager-owned lifecycle accounting.
|
|
54
|
+
|
|
55
|
+
The container-first FleetView and bounded drill-down patterns are independently adapted from tintinweb/pi-subagents at [`221df02323c6b6350ea8474579e0ee0f6cc84cea`](https://github.com/tintinweb/pi-subagents/tree/221df02323c6b6350ea8474579e0ee0f6cc84cea/src/ui) and [`fd8d81618d80ae83a3faf40cb87dae99cca33264`](https://github.com/tintinweb/pi-subagents/tree/fd8d81618d80ae83a3faf40cb87dae99cca33264/src/ui). Collision-only child-label discriminators are inspired by [nicobailon/pi-subagents `69673c7fbf3351e54a5b0786a7e9d9fff625a749`](https://github.com/nicobailon/pi-subagents/blob/69673c7fbf3351e54a5b0786a7e9d9fff625a749/src/tui/render-helpers.ts). This is **not** the upstream workflow engine or full phase inspector: there are no inferred workflow names, phases, statuses, or workflow-wide controls, no new RPC, and no workflow source imports.
|
|
56
|
+
|
|
57
|
+
The opt-in parent clarification broker is locally implemented. It uses the existing main session, not an upstream LLM arbitrator, cloned parent history, private UI automation, or an additional Agent per question. Permission gates remain human-owned.
|
|
50
58
|
|
|
51
59
|
## Lint policy
|
|
52
60
|
|
|
@@ -71,6 +79,40 @@ At a TUI prompt, `@explore find it` starts one background Explore agent. In mode
|
|
|
71
79
|
|
|
72
80
|
**Pi runtime requirement:** On hosts where child sessions require the parent's `ModelRuntime`, Pi currently exposes it to extensions only through the registry's private `.runtime` property. If that bridge is missing or incompatible, Agent, mentions, and managed spawns refuse before allocating an ID or making a provider request; they do not silently construct a fresh runtime that would lose custom providers, credentials, or virtual routes. Existing managed idempotency keys remain readable. An explicit child model is validated against the parent runtime *after* final model selection, so a stale `ctx.model` alone does not block a valid physical child model. The error advises updating to a host that exposes the parent runtime, or disabling dispatch until one does.
|
|
73
81
|
|
|
82
|
+
### Opt-in parent clarification (background only)
|
|
83
|
+
|
|
84
|
+
By default, `contact_supervisor` still asks the human. Explicitly opt a **fresh, non-isolated top-level background** child into ordinary main-agent clarification:
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
Agent({
|
|
88
|
+
subagent_type: "Explore",
|
|
89
|
+
prompt: "Investigate the schema. Ask only ordinary task clarifications, never approval.",
|
|
90
|
+
description: "Investigate schema",
|
|
91
|
+
run_in_background: true,
|
|
92
|
+
supervisor_questions: "parent",
|
|
93
|
+
})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The child awaits an in-process answer promise. Only its explicit bounded question reaches the main model: sanitized request-local context contains a one-use `questionId`, original `agentId`, and numbered choice tokens. The main-only `answer_subagent_question({ questionId, answer })` accepts free text, an offered `option:N` token, or an empty answer to decline. Display labels are separate from the original choice data returned to the child; duplicate identical answers never resume it twice. Question text is untrusted data, not new instructions or authorization.
|
|
97
|
+
|
|
98
|
+
The broker coalesces at most 16 pending questions (one per child), issues at most 64 IDs per session/branch generation, and expires unanswered questions after two minutes. Each question is displayed in hidden request-local context at most once. It requests one normal prepared extension turn only when the root is idle, has no queued messages, has no **observed input/preparation** in progress, and is not waiting on a dialog; otherwise questions join the next ordinary model context or wake after final `agent_settled`, never at retry-prone `agent_end`. No question means no extra model request. It does not edit genuine user text or images, transfer parent history/credentials into the child, poll a model, persist a separate question history, or automatically retry unanswered questions.
|
|
99
|
+
|
|
100
|
+
Idle wakes are factory-local authenticated control tokens, not a claim on a familiar sentence. Recognition requires `input.source === "extension"` and an exact namespace/nonce/MAC; an interactive/RPC literal, foreign-namespace extension literal, or invalid MAC remains ordinary input. Admission replaces only the extension-owned input with a distinct frame token. While that admission remains unambiguous, preparation adds an authenticated **empty-content** native custom message as prompt-local frame evidence, never persistent model guidance. Native `message_start` identifies the executing frame; the promptless `agent_start` and old branch messages cannot identify it. Superseding genuine input invalidates wake authorization without forgetting accepted control identity. Request-local context removes only proven control pairs; verbatim user quotations remain unchanged. Historical proof recognition uses a fixed-size secret and constant recognition state, not an accumulating ledger. At most 16 unresolved or ambiguous admissions are retained independently of the flight; an exhausted admission budget refuses further wakes rather than forgetting unresolved frames.
|
|
101
|
+
|
|
102
|
+
`before_agent_start` exposes no input source or delivery ID: token text alone cannot prove identity. If genuine INPUT copies an admitted token **before** its preparation reaches this package, both matching frames become ambiguous. That admission is retained inert within the same cap, neither frame receives a control proof or question context, neither is aborted or stripped by token text, and the flight's unannounced questions receive structured no-answer. The extension does not alter literal text or images. Native SDK image normalization can append a hint and resize an attachment; question suppression still requires the first text's authenticated token boundary and its retained ambiguous admission, never generic hint prose. This evidence cannot authorize a control proof or abort. The package cannot atomically cancel the unidentifiable control request before native persistence/provider dispatch. Ambiguous preparations cannot release a genuine input's readiness; idle wakes remain conservatively fenced until session retirement, but later ordinary inputs can still receive new questions normally. An already-prepared wake retains its positive native proof and cannot donate it to a later literal quotation.
|
|
103
|
+
|
|
104
|
+
An owned request-local `context` delivery is the ACK. Each question has **one wake attempt**: no ACK within 30 seconds clears only that flight and returns structured no-answer for its still-unannounced questions. Async native authentication rejection and earlier input interception cannot occupy the flight indefinitely or retry acknowledged questions. A superseding genuine prompt retains its own preparation readiness; except for the explicit copied-token ambiguity above, its questions join that prompt or expire normally. Executing-frame ownership separately captures the public `ctx.signal` and its matching native `agent_end`. A competing preparation's busy-failure `agent_settled` cannot clear that frame while the core operation still owns a public signal; only a proven ended and released owner permits cleanup. Native preparation can record inert control tokens and empty frame proofs in ordinary session history; question text and answers are not stored as separate broker entries.
|
|
105
|
+
|
|
106
|
+
**Public-hook limits:** handlers run in registration/load order. Observed genuine INPUT retires idle readiness synchronously, before later awaits. This package's mention interception releases only its own INPUT-scoped preparation on a native `handled` result; a thrown handler is not handled (Pi reports it and continues), and another concurrently preparing input is never released by that mention. Pi exposes neither an earlier handler's in-progress input nor a generic input-handled/auth-rejection completion notification. External handling/rejection after observation can therefore retain preparation readiness until session retirement; no timer guesses that a genuine prompt is inactive. Unresolved accepted wake preparations also retain their bounded admission slots until preparation arrives or the activation is replaced.
|
|
107
|
+
|
|
108
|
+
Pi has no cancellable post-preparation/pre-persistence boundary. Already-prepared competing prompts cannot be atomically serialized or cancelled before native persistence through public hooks. Cancellation can leave an inert native control record; a proven invalidated control frame is never reclassified as ordinary instructions or given question context. Abort is issued only after an authenticated native message boundary proves the currently executing owned frame, never from a global accepted-token guess or to stop a genuine/other run. SDK 1.0.4 coverage includes later-preparation release orders, user/proof message-start and message-end awaits, copied unprepared tokens with and without images, default AUTO resizing of real 4096x4096 PNGs in both release orders while another child's question stays pending, and guard-disabled controls. Native concurrent prompts can still reject with “already processing”; the package preserves observed frame identity but does not guarantee native concurrent-prompt acceptance or atomic pre-persistence cancellation. This is not a private SDK/renderer/history patch.
|
|
109
|
+
|
|
110
|
+
Answers are **not human approval or permission grants**. `ask_tools`, `createAskGate`, project trust, external permission dialogs, and human rejections are unchanged. A decision outside existing user task authorization must be declined and left to the human. Cancellation (including an aborted main turn), child stop/removal, timeout, navigation, or reload returns structured no-answer/assumption guidance, never approval. Pending IDs capture the public root session-manager identity, session ID, and complete native branch-entry prefix (including non-model metadata) at child channel construction **and at every question invocation from the live root context**, plus the current child attempt. A terminal child's background resume keeps its channel guard but cannot reuse that older prefix as the origin of a new question. Admission, context reads, and answer dispatch validate both live ancestries, not only generation events or model-text projections. Ordinary appends retain ownership; metadata-distinct divergent ancestry retires it even if an earlier asynchronous committed-tree handler has delayed this extension's notification. Old IDs cannot answer a replacement run.
|
|
111
|
+
|
|
112
|
+
Foreground parent-mode calls are rejected **before spawn**: a main model blocked waiting for a child cannot execute another answering tool. Schedules and changing question mode on `resume` are also unsupported. An existing parent-mode child can resume only in background with `supervisor_questions` omitted; it retains its mode. Do not call `get_subagent_result(wait: true)` while an ordinary clarification is pending: that wait blocks the answering model until cancellation or the question timeout. Nested children cannot opt in or call the root answering tool. Managed protocol-v4 requests do not carry this new mode and remain unchanged; this MVP supports direct `Agent` calls and in-process `SpawnOptions.supervisorQuestions: "parent"` only when the root manager has issued the channel. No new global configuration is added. The existing Supervisor questions setting must remain enabled; `supervisor_questions: "off"` withholds contact for one fresh call.
|
|
113
|
+
|
|
114
|
+
Install the changed package and reload/restart Pi to use this feature; an already running older installed package does not acquire new tools or parameters from checkout edits.
|
|
115
|
+
|
|
74
116
|
### Scheduling
|
|
75
117
|
|
|
76
118
|
Add a `schedule` field to register the agent to fire later instead of running now:
|
|
@@ -114,7 +156,7 @@ above-editor widget is gone.
|
|
|
114
156
|
Runs that enter through the scheduler or a cross-extension spawn appear in the
|
|
115
157
|
list too, alongside `Agent`-tool runs.
|
|
116
158
|
|
|
117
|
-
Every string these surfaces take from a child run — its description, activity preview, error, the `Agent` tool's own result and completion notification, and the conversation overlay's messages, tool results and command output — is text the extension did not author, so it is neutralized before rendering
|
|
159
|
+
Every string these surfaces take from a child run — its description, activity preview, error, the `Agent` tool's own result and completion notification, and the conversation overlay's messages, tool results and command output — is text the extension did not author, so it is neutralized before rendering. Fleet, conversation labels and scheduled-job selectors/confirmations/notifications use `@signalridge/pi-ui` display encoding (controls shown as `\\uXXXX`). Transcript previews drop terminal escape sequences, name remaining unsafe code points as `[U+XXXX]`, and replace binary content with a placeholder. These are display copies, not changes to stored prompts, paths, or messages. An agent file's `display_name` and `description` get the same treatment as they are loaded, since a cloned repository supplies them. A subagent that reads a poisoned file or fetches a hostile page cannot repaint or reorder the parent's terminal.
|
|
118
160
|
|
|
119
161
|
The token field is annotated with two optional signals inside parens:
|
|
120
162
|
- **`NN%`** — context-window utilization (color-coded: <70% dim, 70–85% warning, >=85% error). Omitted when the model has no declared `contextWindow`, or briefly right after compaction.
|
|
@@ -143,6 +185,10 @@ the rest of the row out of alignment on some terminals and not others.
|
|
|
143
185
|
|
|
144
186
|
The list is ordered earliest-launched first, and only shows agents you can actually open (pending/queued agents with no session yet appear once they start). At an **empty prompt**, press down or left to move focus from the prompt into the list. Up and down move the selection, Enter opens the selected agent's live conversation overlay (it auto-updates as the agent works), and Esc (or up above `main`) returns to the prompt. Selecting `main` returns to the normal view. The conversation overlay uses a complete `╭─╮` / `│` / `╰─╯` border. Press Enter to chat with a running agent — type a message and Enter to send it (Esc or an empty submit returns). For a queued agent that has no session yet, type `@handle message` in the main prompt; the message is delivered when it starts. Press `x` twice to stop a running agent. A viewer stays open when its agent finishes so you can read the final output, and finished agents linger in the list for a few seconds before dropping out. Typing anything at a non-empty prompt behaves normally — the list only captures navigation keys when the prompt is empty. Disable it entirely via `/agents → Settings → Fleet view`.
|
|
145
187
|
|
|
188
|
+
Workflow-owned records appear in container rows **above ordinary agents**: `Workflow <runId> · N retained children`. These groups use only validated, already-available `owner.extension` and `owner.runId`; their counts describe records the manager still retains, not total workflow calls or authoritative run status. Opening one gives a terminal-height-bounded child list with stable-ID selection, Up/Down, PageUp/PageDown, Home/End and Enter to view a conversation; Esc returns from the conversation to the child list, then to FleetView. Sessionless queued children remain visible with an explicit unavailable-session message. Completed owned children remain inspectable while retained, independent of the ordinary four-second linger. Duplicate descriptions receive an ID discriminator only when needed. Detached records are excluded, and controls opened on an old thread attempt cannot steer/stop a replacement attempt.
|
|
189
|
+
|
|
190
|
+
Steering rejection (including a child finishing while the composer is open) preserves the draft and shows an error; Esc explicitly discards it. Completed conversation headers fall back to the retained lifetime usage and compaction count when live activity has been removed.
|
|
191
|
+
|
|
146
192
|
Individual agent results use restrained text status labels:
|
|
147
193
|
|
|
148
194
|
| State | Example |
|
|
@@ -231,7 +277,7 @@ All fields are optional — sensible defaults for everything.
|
|
|
231
277
|
| `description` | filename | Agent description shown in tool listings |
|
|
232
278
|
| `display_name` | — | Display name for UI (e.g. the agent list, the conversation overlay) |
|
|
233
279
|
| `tools` | all 7 | Which tools the agent can call. Built-in names (`read, grep, …`), `*` / `all` (all built-ins), `none`, and `ext:<extension>` / `ext:<extension>/<tool>` selectors for extension tools. See [Tool & extension scoping](#tool--extension-scoping) below |
|
|
234
|
-
| `extensions` | `true` | Which extensions to load for the agent. `true` (all defaults), `false` (none), or an explicit list: `[mcp, "/abs/path.ts", "*"]`. See [Tool & extension scoping](#tool--extension-scoping) below |
|
|
280
|
+
| `extensions` | `true` | Which extensions to load for the agent. `true` (all child-safe defaults), `false` (none), or an explicit list: `[mcp, "/abs/path.ts", "*"]`. `pi-background` is excluded; intentional selection is refused by name. See [Tool & extension scoping](#tool--extension-scoping) below |
|
|
235
281
|
| `exclude_extensions` | — | Extension denylist applied after `extensions:` — exclude wins. Plain names only (case-insensitive), no paths or `*`. Useful with `extensions: true` to drop one extension (e.g. `pi-notify`) |
|
|
236
282
|
| `skills` | `true` | `true` uses Pi's discovered skills, including skills supplied by loaded extensions; `false` exposes none; a comma-separated list preloads only those named skills into the system prompt (see [Skill Preloading](#skill-preloading) for discovery locations) |
|
|
237
283
|
| `memory` | — | Persistent agent memory scope: `project`, `local`, or `user`. Auto-detects read-only agents |
|
|
@@ -295,7 +341,7 @@ Genuine user/global resources outside denied roots remain available subject to t
|
|
|
295
341
|
`extensions:` decides **which extensions load**, `tools:` decides **which tools surface to the LLM**. They compose:
|
|
296
342
|
|
|
297
343
|
```yaml
|
|
298
|
-
# Default (both omitted):
|
|
344
|
+
# Default (both omitted): child-safe extensions load, all 7 built-ins surface
|
|
299
345
|
|
|
300
346
|
tools: read, grep, find # narrow to listed built-ins; extensions still load
|
|
301
347
|
tools: "*" # all 7 built-ins (alias: `all`)
|
|
@@ -317,16 +363,18 @@ isolated: true # hermetic: built-ins only, no extensions/skil
|
|
|
317
363
|
|
|
318
364
|
A few rules the examples don't make obvious:
|
|
319
365
|
|
|
320
|
-
- `extensions:` is the
|
|
366
|
+
- `extensions:` is the loading authority, subject to child lifecycle safety. `pi-background` is excluded **before its factory loads**, including under default inheritance and `"*"`; an `extension-error:…` warning names it. Its jobs can wake a retained child after `session.prompt()` finishes, when the manager has marked the record terminal, released its concurrency slot, and removed its worktree. Until manager-owned asynchronous continuation exists, explicitly selecting `pi-background` (by name, alias, package path, or entry path) is refused by name rather than silently dropped. `ext:pi-background` cannot override this boundary and warns as an unloaded selector. Ordinary extensions remain available; parent sessions are unaffected. This does not add or change another extension's settings.
|
|
367
|
+
- `ext:foo` in `tools:` narrows what surfaces; it can't load `foo` on its own. Mismatches fire `extension-error:…` warnings.
|
|
321
368
|
- Any `ext:` entry flips extension tools to an explicit allowlist — unnamed extensions still load (handlers fire) but expose no tools. So `tools: "*, ext:mcp/search"` exposes only `search` from `mcp`, nothing from any other extension.
|
|
322
369
|
- Extension names match case-insensitively (`[Mcp]` = `[mcp]`); tool names in `ext:foo/bar` stay case-sensitive.
|
|
323
370
|
- Extensions that register tools **lazily** work too. MCP-backed extensions typically can't enumerate their tools until their servers connect, so they register from `session_start` or `before_agent_start` rather than at load. Subagent scoping is re-derived as tools appear, including under `ext:` selectors. Scoping does not override Pi's own exposure: `deferred`/`codemode` tools and `defaultActive: false` tools are not activated for the model, and tools deliberately deactivated during a session stay inactive on later turns. Pi may still allow nested `ctx.executeTool()` calls to deferred/codemode tools, but the child's selector and `ask_tools:` approval gate both model-issued and nested calls.
|
|
324
371
|
- A configured `defaultToolTimeoutMs` bounds the public `tool_call` policy and each selected extension handler using one deadline per call, including nested calls and handlers registered later with `pi.on`. On expiry the call is blocked; a Promise race returns control but **cannot cancel side effects** of a misbehaving hook that keeps running. Interactive `ask_tools:` confirmation is human decision time, not tool execution time: the deadline starts after approval. The separate execution timeout can still abort a tool whose `execute()` hangs.
|
|
325
|
-
- When the SDK exports public codemode, tool-search, and MCP factories (as on the verified Pi 1.0.4 baseline), they are included as built-in extensions in children. `extensions: false` omits them; `extensions: [mcp]` selects MCP; `exclude_extensions: mcp` removes it; and `tools: ext:codemode`, `ext:tool-search`, or `ext:mcp/<tool>` narrows their tools like any other extension. Capability checks omit factories that the host does not export; this fallback is not an older-host support guarantee.
|
|
372
|
+
- When the SDK exports public codemode, tool-search, and MCP factories (as on the verified Pi 1.0.4 baseline), they are included as built-in extensions in children. `extensions: false` omits them; `extensions: [mcp]` selects MCP; `exclude_extensions: mcp` removes it; and `tools: ext:codemode`, `ext:tool-search`, or `ext:mcp/<tool>` narrows their tools like any other extension. Capability checks omit factories that the host does not export; this fallback is not an older-host support guarantee. Whenever extensions are enabled, the child uses Pi's public package resolver to select discovered paths **before** loading factories. Thus an excluded extension cannot register a conflicting `/mcp` command and displace a selected replaceable built-in before filtering. Resolution can perform configured package installs, as Pi's normal loader does, and a selected extension that fails to load is reported rather than silently dropped.
|
|
373
|
+
- Explicit sources are validated separately before canonical deduplication, so a real file plus its file alias, or two package-directory aliases, load once without falsely reporting a missing source. Ownership names are unioned for admission/exclusion, but whole-package priority uses each resource's declaring package; direct entries use the canonical declaring owner. A package that aliases another package's entry does not lose its unrelated entries when the target is explicitly selected. Directory-backed modules are matched to Node's supported module entry without evaluating factories; undeclared siblings are not attributed to that package.
|
|
326
374
|
- An installed **package** extension matches by its package short name (`@scope/pi-subagents` → `[pi-subagents]`), in addition to its path-derived name (a package whose entry is `src/index.ts` also answers to `[src]`). Prefer the package name — the path-derived one is incidental.
|
|
327
375
|
- Plain `tools:` typos fail loudly: `tools: reed, grep` fires `tools-error:…` instead of silently producing an under-tooled agent.
|
|
328
376
|
- `exclude_extensions:` wins over `extensions:` and over `ext:` selectors — an excluded extension never loads and a `tools: ext:` entry can't pull it back. Plain names only (no paths, no `*`); a name matching nothing fires an `extension-error:…` warning.
|
|
329
|
-
- `exclude_extensions:` is **not a sandbox**:
|
|
377
|
+
- `exclude_extensions:` is **not a sandbox**: preselection prevents excluded discovered factories from loading into the child, but resolving configured packages can still install code. Allowed extensions run with the Pi process's permissions. Don't rely on extension selection to contain untrusted packages.
|
|
330
378
|
- Array and string forms are equivalent: `[a, b]` == `"a, b"`.
|
|
331
379
|
|
|
332
380
|
## Tools
|
|
@@ -343,6 +391,7 @@ Launch a sub-agent.
|
|
|
343
391
|
| `tier` | string | no | Model tier for this spawn, by name. Overrides the agent's own default tier. Unknown tiers are rejected, not substituted — see [Model tiers](#model-tiers) |
|
|
344
392
|
| `max_turns` | number | no | Max agentic turns. Omit for unlimited (default) |
|
|
345
393
|
| `run_in_background` | boolean | no | Run without blocking |
|
|
394
|
+
| `supervisor_questions` | `"human"` / `"parent"` / `"off"` | no | Default human; parent routes only ordinary clarification from fresh non-isolated background calls. No schedules or mode-changing resumes |
|
|
346
395
|
| `resume` | string | no | Agent ID to resume a previous session |
|
|
347
396
|
| `isolated` | boolean | no | No extension/MCP tools |
|
|
348
397
|
| `isolation` | `"worktree"` | no | Run in an isolated git worktree |
|
|
@@ -369,6 +418,10 @@ Send a chat message to a running or queued agent. A running agent receives it af
|
|
|
369
418
|
| `agent_id` | string | yes | Agent ID to steer |
|
|
370
419
|
| `message` | string | yes | Message to inject into agent conversation |
|
|
371
420
|
|
|
421
|
+
### `answer_subagent_question` (main only)
|
|
422
|
+
|
|
423
|
+
Answer a current opted-in background child's ordinary clarification: `questionId` is the bounded one-use ID in extension context; `answer` is at most 2,000 characters, an offered `option:N` token for choices, or empty to decline. Forged, expired, cancelled, or stale IDs and unoffered choices are rejected. The tool is never inherited or injected into children and cannot grant permission or override a human rejection.
|
|
424
|
+
|
|
372
425
|
## Commands
|
|
373
426
|
|
|
374
427
|
| Command | Description |
|
|
@@ -966,11 +1019,14 @@ src/
|
|
|
966
1019
|
prompts.ts # Config-driven system prompt builder
|
|
967
1020
|
context.ts # Pure parent-context renderer (not used for child dispatch)
|
|
968
1021
|
context-boundary.ts # Raw inheritance and persisted-session guards
|
|
1022
|
+
parent-questions.ts # Opt-in clarification broker, authenticated bounded outbox, public preparation/ACK gates
|
|
1023
|
+
question-origin.ts # Public manager identity and complete native branch-prefix ownership
|
|
969
1024
|
model-runtime-bridge.ts # Checked parent runtime compatibility bridge
|
|
970
1025
|
env.ts # Environment detection (git, platform)
|
|
971
1026
|
ui/
|
|
972
1027
|
agent-display.ts # Shared agent formatting: spinner frames, activity, stats, names
|
|
973
1028
|
conversation-viewer.ts # Live conversation overlay for viewing agent sessions
|
|
1029
|
+
owner-group.ts # Bounded retained workflow-child inspector (no engine coupling)
|
|
974
1030
|
select-item.ts # Numbered identity-safe selectors for duplicate labels
|
|
975
1031
|
```
|
|
976
1032
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signalridge/pi-subagents",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "Signalridge's managed subagent runtime with workflow-owned orchestration RPC.",
|
|
5
5
|
"author": "tintinweb and signalridge contributors",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"croner": "^10.0.1",
|
|
35
35
|
"@signalridge/pi-subagents-protocol": "^1.5.1",
|
|
36
|
-
"@signalridge/pi-ui": "^1.
|
|
36
|
+
"@signalridge/pi-ui": "^1.4.0",
|
|
37
37
|
"minimatch": "^10.2.6",
|
|
38
38
|
"nanoid": "^5.1.16"
|
|
39
39
|
},
|
package/src/agent-color.ts
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* interpolated from the file, so nothing else can ride along inside it.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { sanitizeDisplayText } from "@signalridge/pi-ui";
|
|
21
22
|
import { getConfig } from "./agent-types.js";
|
|
22
|
-
import { sanitizeDisplayText } from "./ui/safe-text.js";
|
|
23
23
|
|
|
24
24
|
const NAMED_AGENT_COLORS: Readonly<Record<string, string>> = {
|
|
25
25
|
// Claude Code's eight subagent colors, as its default theme renders them.
|
package/src/agent-manager.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
assertParentModelRuntimeAvailable,
|
|
44
44
|
parentModelSessionOptions,
|
|
45
45
|
} from "./model-runtime-bridge.js";
|
|
46
|
+
import type { ParentQuestionHandler } from "./parent-questions.js";
|
|
46
47
|
import { shutdownAndDisposeSession } from "./session-lifecycle.js";
|
|
47
48
|
import type { TierThinking } from "./settings.js";
|
|
48
49
|
import type {
|
|
@@ -314,6 +315,7 @@ interface SpawnArgs {
|
|
|
314
315
|
prompt: string;
|
|
315
316
|
options: SpawnOptions;
|
|
316
317
|
internalOverride?: InternalAgentConfigOverride;
|
|
318
|
+
parentQuestion?: ParentQuestionHandler;
|
|
317
319
|
}
|
|
318
320
|
|
|
319
321
|
export type ManagedSpawnRequest = ProtocolManagedSpawnRequest;
|
|
@@ -912,6 +914,8 @@ export interface SpawnOptions {
|
|
|
912
914
|
/** Named sequential-thread hint for workflow orchestration. */
|
|
913
915
|
thread?: string;
|
|
914
916
|
isBackground?: boolean;
|
|
917
|
+
/** Explicit clarification routing. Parent mode requires a fresh root background spawn. */
|
|
918
|
+
supervisorQuestions?: boolean | "parent";
|
|
915
919
|
/**
|
|
916
920
|
* Skip the maxConcurrent queue check for this spawn — start immediately even
|
|
917
921
|
* if the configured concurrency limit would otherwise queue it. Used by the
|
|
@@ -1074,6 +1078,13 @@ export class AgentManager {
|
|
|
1074
1078
|
private rememberAgents = true;
|
|
1075
1079
|
/** Whether spawned agents get `contact_supervisor` (settings). */
|
|
1076
1080
|
private supervisorQuestions = true;
|
|
1081
|
+
private parentQuestionFactory?: (record: AgentRecord, ctx: ExtensionContext) => ParentQuestionHandler;
|
|
1082
|
+
private parentQuestionAgents = new WeakSet<AgentRecord>();
|
|
1083
|
+
|
|
1084
|
+
/** Package-local root capability; no managed RPC schema or global settings change. */
|
|
1085
|
+
setParentQuestionFactory(factory: (record: AgentRecord, ctx: ExtensionContext) => ParentQuestionHandler): void {
|
|
1086
|
+
this.parentQuestionFactory = factory;
|
|
1087
|
+
}
|
|
1077
1088
|
/**
|
|
1078
1089
|
* Descendants spawned so far under each top-level agent, keyed by that
|
|
1079
1090
|
* agent's id. Cumulative for the branch's lifetime — see the budget check in
|
|
@@ -1663,6 +1674,14 @@ export class AgentManager {
|
|
|
1663
1674
|
// Legacy in-process callers omitted description before it became required.
|
|
1664
1675
|
// Normalize that shape to an inert string while rejecting reference values.
|
|
1665
1676
|
options = { ...options, description: options.description ?? type };
|
|
1677
|
+
if (options.supervisorQuestions !== undefined && typeof options.supervisorQuestions !== "boolean" && options.supervisorQuestions !== "parent") {
|
|
1678
|
+
throw new Error('supervisorQuestions must be boolean or "parent".');
|
|
1679
|
+
}
|
|
1680
|
+
if (options.supervisorQuestions === "parent" &&
|
|
1681
|
+
(options.isBackground !== true || options.parentAgentId !== undefined || options.resumeSessionFile ||
|
|
1682
|
+
options.isolated || getAgentConfig(type)?.isolated || !this.supervisorQuestions || !this.parentQuestionFactory)) {
|
|
1683
|
+
throw new Error('Parent clarification requires a fresh, non-isolated top-level background Agent with supervisor questions enabled. Set run_in_background: true; foreground calls cannot answer while blocked.');
|
|
1684
|
+
}
|
|
1666
1685
|
// Covers top-level, nested, managed, and queued spawns before allocating a
|
|
1667
1686
|
// record, consuming a branch budget, or creating an isolation worktree.
|
|
1668
1687
|
assertNoRawInheritance(
|
|
@@ -1811,7 +1830,9 @@ export class AgentManager {
|
|
|
1811
1830
|
prompt,
|
|
1812
1831
|
options,
|
|
1813
1832
|
internalOverride,
|
|
1833
|
+
...(options.supervisorQuestions === "parent" ? { parentQuestion: this.parentQuestionFactory!(record, ctx) } : {}),
|
|
1814
1834
|
};
|
|
1835
|
+
if (options.supervisorQuestions === "parent") this.parentQuestionAgents.add(record);
|
|
1815
1836
|
// Lifecycle observers may synchronously stop or dispose a freshly allocated
|
|
1816
1837
|
// record. Do not let the normal queue/start path resurrect that decision.
|
|
1817
1838
|
if (
|
|
@@ -2215,7 +2236,7 @@ export class AgentManager {
|
|
|
2215
2236
|
private startAgent(
|
|
2216
2237
|
id: string,
|
|
2217
2238
|
record: AgentRecord,
|
|
2218
|
-
{ pi, ctx, type, prompt, options, internalOverride }: SpawnArgs,
|
|
2239
|
+
{ pi, ctx, type, prompt, options, internalOverride, parentQuestion }: SpawnArgs,
|
|
2219
2240
|
) {
|
|
2220
2241
|
if (this.disposed || record.detached || this.agents.get(id) !== record)
|
|
2221
2242
|
return;
|
|
@@ -2330,7 +2351,8 @@ export class AgentManager {
|
|
|
2330
2351
|
// Top-level conversations persist by default so `@handle` has something
|
|
2331
2352
|
// to reopen after the record is evicted; frontmatter still overrides.
|
|
2332
2353
|
rememberAgents: this.rememberAgents,
|
|
2333
|
-
supervisorQuestions: this.supervisorQuestions,
|
|
2354
|
+
supervisorQuestions: options.supervisorQuestions ?? this.supervisorQuestions,
|
|
2355
|
+
parentQuestion,
|
|
2334
2356
|
resumeSessionFile: options.resumeSessionFile,
|
|
2335
2357
|
toolset: options.toolset,
|
|
2336
2358
|
excludeTools: options.excludeTools,
|
|
@@ -2948,6 +2970,9 @@ export class AgentManager {
|
|
|
2948
2970
|
)
|
|
2949
2971
|
return undefined;
|
|
2950
2972
|
if (!record.session) return undefined;
|
|
2973
|
+
if (this.parentQuestionAgents.has(record) && options?.isBackground !== true) {
|
|
2974
|
+
throw new Error('This child uses parent clarification; resume it with run_in_background: true to avoid blocking the answering model.');
|
|
2975
|
+
}
|
|
2951
2976
|
assertSafeChildSession(
|
|
2952
2977
|
record.session,
|
|
2953
2978
|
record.invocation?.inheritContext === true,
|
package/src/agent-runner.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
DefaultResourceLoader,
|
|
18
18
|
type ExtensionAPI,
|
|
19
19
|
getAgentDir,
|
|
20
|
+
type ResolvedResource,
|
|
20
21
|
SessionManager,
|
|
21
22
|
SettingsManager,
|
|
22
23
|
} from "@earendil-works/pi-coding-agent";
|
|
@@ -28,6 +29,7 @@ import { extractText } from "./context.js";
|
|
|
28
29
|
import { assertNoRawInheritance, assertSafeChildSession } from "./context-boundary.js";
|
|
29
30
|
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
30
31
|
import { detectEnv } from "./env.js";
|
|
32
|
+
import { extensionResourceIdentity, resolveExtensionResourceNames } from "./extension-resource-names.js";
|
|
31
33
|
import { formatGateVerdict, type GateExec, runGate, workspaceFingerprint } from "./gate.js";
|
|
32
34
|
import {
|
|
33
35
|
INTERNAL_AGENT_CONFIG_OVERRIDE,
|
|
@@ -40,6 +42,7 @@ import { type ModelRegistry, resolveModel, shortModelLabel } from "./model-resol
|
|
|
40
42
|
import { parentModelSessionOptions } from "./model-runtime-bridge.js";
|
|
41
43
|
import { checkModelScope } from "./model-scope.js";
|
|
42
44
|
import { createNestedSubagentTools, getMaxSubagentDepth, type NestedAgentManager } from "./nested-tools.js";
|
|
45
|
+
import { ANSWER_SUBAGENT_QUESTION, type ParentQuestionHandler } from "./parent-questions.js";
|
|
43
46
|
import { captureProjectTrust, childProjectTrust, configurationMcpFactory, isProjectResource } from "./project-trust.js";
|
|
44
47
|
import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
|
|
45
48
|
import { shutdownAndDisposeSession } from "./session-lifecycle.js";
|
|
@@ -57,6 +60,7 @@ export const SUBAGENT_TOOL_NAMES = {
|
|
|
57
60
|
AGENT: "Agent",
|
|
58
61
|
GET_RESULT: "get_subagent_result",
|
|
59
62
|
STEER: "steer_subagent",
|
|
63
|
+
ANSWER_QUESTION: ANSWER_SUBAGENT_QUESTION,
|
|
60
64
|
} as const;
|
|
61
65
|
|
|
62
66
|
/** Names of tools registered by this extension that subagents must NOT inherit. */
|
|
@@ -241,8 +245,9 @@ function extensionToolNamesInScope(session: AgentSession, ctx: {
|
|
|
241
245
|
extNames: Set<string>;
|
|
242
246
|
narrowing: Map<string, Set<string>>;
|
|
243
247
|
nestedToolNames: Set<string>;
|
|
248
|
+
resolvedNames: Map<string, string[]>;
|
|
244
249
|
}): () => Set<string> {
|
|
245
|
-
const { loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames } = ctx;
|
|
250
|
+
const { loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames, resolvedNames } = ctx;
|
|
246
251
|
const builtins = new Set(toolNames);
|
|
247
252
|
return () => {
|
|
248
253
|
const keep = new Set<string>();
|
|
@@ -258,7 +263,7 @@ function extensionToolNamesInScope(session: AgentSession, ctx: {
|
|
|
258
263
|
const path = tool.sourceInfo.path;
|
|
259
264
|
const extension = extensionsByPath.get(path);
|
|
260
265
|
if (extension) {
|
|
261
|
-
const canons = extensionCanonicalNames(extension.path);
|
|
266
|
+
const canons = resolvedNames.get(extension.path) ?? extensionCanonicalNames(extension.path);
|
|
262
267
|
if (optInActive && !canons.some((c) => extNames.has(c))) continue;
|
|
263
268
|
// First alias that carries a narrowing set — a user won't narrow one
|
|
264
269
|
// extension under two different names, so first-match is correct.
|
|
@@ -684,7 +689,9 @@ export interface RunOptions {
|
|
|
684
689
|
* Defaults to on wherever there is a UI to ask through; `false` withholds the
|
|
685
690
|
* tool entirely rather than injecting one that always refuses.
|
|
686
691
|
*/
|
|
687
|
-
supervisorQuestions?: boolean;
|
|
692
|
+
supervisorQuestions?: boolean | "parent";
|
|
693
|
+
/** Manager-issued root clarification channel; never supplied by child tool args. */
|
|
694
|
+
parentQuestion?: ParentQuestionHandler;
|
|
688
695
|
/** Runtime bridge for opt-in child-safe nested delegation. */
|
|
689
696
|
nestedRuntime?: {
|
|
690
697
|
manager: NestedAgentManager;
|
|
@@ -901,6 +908,9 @@ export async function runAgent(
|
|
|
901
908
|
// an already-cancelled caller. This matters for tool-call signals: the
|
|
902
909
|
// caller may be cancelled before this async function gets its first turn.
|
|
903
910
|
throwIfAborted(options.signal);
|
|
911
|
+
if (options.supervisorQuestions === "parent" && !options.parentQuestion) {
|
|
912
|
+
throw new Error('Parent clarification requires a manager-issued background channel. Use Agent with run_in_background: true and supervisor_questions: "parent".');
|
|
913
|
+
}
|
|
904
914
|
const parentProjectTrust = captureProjectTrust(ctx);
|
|
905
915
|
// Configuration provenance is established synchronously, before definition
|
|
906
916
|
// admission, model setup, callbacks, or any await. Execution cwd is not trust.
|
|
@@ -1085,12 +1095,12 @@ export async function runAgent(
|
|
|
1085
1095
|
const settingsManager = SettingsManager.create(configCwd, agentDir, { projectTrusted });
|
|
1086
1096
|
|
|
1087
1097
|
// Extension loading:
|
|
1088
|
-
// - true → all default-discovered extensions
|
|
1098
|
+
// - true → all child-safe default-discovered extensions
|
|
1089
1099
|
// - false → none (noExtensions)
|
|
1090
1100
|
// - string[] → allowlist. Bare names keep matching default-discovered
|
|
1091
1101
|
// extensions; path entries load explicitly; "*" keeps all defaults.
|
|
1092
|
-
//
|
|
1093
|
-
//
|
|
1102
|
+
// Resolve enabled resources before loading: excluded factories cannot
|
|
1103
|
+
// replace selected built-ins or schedule unmanaged asynchronous wakes.
|
|
1094
1104
|
//
|
|
1095
1105
|
// Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md — upstream's
|
|
1096
1106
|
// buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
|
|
@@ -1111,12 +1121,11 @@ export async function runAgent(
|
|
|
1111
1121
|
: undefined;
|
|
1112
1122
|
const keepNames = extensionsSpec?.names ?? new Set<string>();
|
|
1113
1123
|
// `exclude_extensions:` is a denylist applied AFTER the include set — exclude wins.
|
|
1114
|
-
// Plain canonical names only (case-insensitive).
|
|
1115
|
-
//
|
|
1124
|
+
// Plain canonical names only (case-insensitive). Preselection prevents
|
|
1125
|
+
// factory execution, but resolving packages is not an untrusted-code sandbox.
|
|
1116
1126
|
const excludeNames = new Set((excludeExtensions ?? []).map((n) => n.toLowerCase()));
|
|
1117
1127
|
const hasExcludes = excludeNames.size > 0;
|
|
1118
|
-
// The override
|
|
1119
|
-
// and ALWAYS puts our tool policy first. Pi passes one mutable
|
|
1128
|
+
// The override validates preselection and ALWAYS puts our tool policy first. Pi passes one mutable
|
|
1120
1129
|
// tool_call event through handlers in extension order; if an earlier extension
|
|
1121
1130
|
// changes event.toolName, a later gate would inspect a different name from the
|
|
1122
1131
|
// one Pi actually executes. Ordering before untrusted handlers is essential.
|
|
@@ -1130,6 +1139,7 @@ export async function runAgent(
|
|
|
1130
1139
|
let discoveredNames: Set<string> | undefined;
|
|
1131
1140
|
let prefilteredExtensionPaths: Set<string> | undefined;
|
|
1132
1141
|
const resolvedExplicitNames = new Set<string>();
|
|
1142
|
+
let resolvedNames = new Map<string, string[]>();
|
|
1133
1143
|
const interactiveAskNames = new Set(ctx.hasUI ? (agentConfig?.askTools ?? []).map((name) => name.toLowerCase()) : []);
|
|
1134
1144
|
const approvalTimer: ApprovalTimer = {
|
|
1135
1145
|
isInteractiveAsk: (name) => interactiveAskNames.has(name.toLowerCase()),
|
|
@@ -1224,11 +1234,12 @@ export async function runAgent(
|
|
|
1224
1234
|
// discovered extension can register /mcp, replace builtin:mcp, and only then
|
|
1225
1235
|
// get filtered out by our allowlist. Resolve configured resources through the
|
|
1226
1236
|
// public package API first and feed only the selected paths into a loader with
|
|
1227
|
-
// discovery off.
|
|
1228
|
-
//
|
|
1237
|
+
// discovery off. Always preselect, even under default inheritance: a
|
|
1238
|
+
// pi-background factory can schedule model wakes beyond session.prompt(),
|
|
1239
|
+
// after the manager has completed the record and cleaned its worktree.
|
|
1229
1240
|
const builtinNames = extensionFactories.filter((factory) => typeof factory !== "function" && factory.builtin)
|
|
1230
1241
|
.map((factory) => factory.name);
|
|
1231
|
-
const prefilter = !noExtensions
|
|
1242
|
+
const prefilter = !noExtensions;
|
|
1232
1243
|
if (prefilter) {
|
|
1233
1244
|
await settingsManager.reload();
|
|
1234
1245
|
throwIfAborted(options.signal);
|
|
@@ -1238,31 +1249,54 @@ export async function runAgent(
|
|
|
1238
1249
|
// state or replace its source-resolution rules with path heuristics.
|
|
1239
1250
|
const configured = await packages.resolve();
|
|
1240
1251
|
throwIfAborted(options.signal);
|
|
1241
|
-
|
|
1242
|
-
|
|
1252
|
+
// Validate each source before cross-source dedup discards valid alias metadata.
|
|
1253
|
+
const explicitSources = new Map<string, ResolvedResource[]>();
|
|
1243
1254
|
for (const source of extensionsSpec?.paths ?? []) {
|
|
1244
|
-
|
|
1255
|
+
const resolved = await packages.resolveExtensionSources([source], { temporary: true });
|
|
1256
|
+
throwIfAborted(options.signal);
|
|
1257
|
+
const enabled = resolved.extensions.filter(resource => resource.enabled);
|
|
1258
|
+
if (!existsSync(source) || enabled.length === 0) {
|
|
1245
1259
|
throw new Error(`Extension source "${source}" requested by agent "${type}" was not found or has no enabled extensions.`);
|
|
1246
1260
|
}
|
|
1261
|
+
explicitSources.set(source, enabled);
|
|
1247
1262
|
}
|
|
1263
|
+
const explicitResources = [...explicitSources.values()].flat();
|
|
1248
1264
|
settingsBeforeLoad = JSON.stringify([settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()]);
|
|
1249
|
-
const enabledConfigured = configured.extensions.filter((resource) => resource.enabled && !deniedProjectResource(resource.path))
|
|
1250
|
-
const enabledExplicit =
|
|
1265
|
+
const enabledConfigured = configured.extensions.filter((resource) => resource.enabled && !deniedProjectResource(resource.path));
|
|
1266
|
+
const enabledExplicit = explicitResources;
|
|
1251
1267
|
for (const resource of enabledExplicit) {
|
|
1252
1268
|
if (deniedProjectResource(resource.path)) {
|
|
1253
1269
|
throw new Error(`Project extension "${resource.path}" requested by agent "${type}" is not trusted.`);
|
|
1254
1270
|
}
|
|
1255
1271
|
}
|
|
1256
|
-
|
|
1272
|
+
const resolved = await resolveExtensionResourceNames(
|
|
1273
|
+
[...configured.extensions.filter(resource => !deniedProjectResource(resource.path)), ...enabledExplicit],
|
|
1274
|
+
packages, extensionCanonicalNames,
|
|
1275
|
+
);
|
|
1276
|
+
throwIfAborted(options.signal);
|
|
1277
|
+
resolvedNames = resolved.names;
|
|
1278
|
+
discoveredNames = new Set([...enabledConfigured, ...enabledExplicit]
|
|
1279
|
+
.flatMap(resource => resolvedNames.get(resource.path) ?? []));
|
|
1280
|
+
let warnedBackground = false;
|
|
1281
|
+
const admitChildExtension = (resource: ResolvedResource, intentional: boolean): boolean => {
|
|
1282
|
+
if (!resolvedNames.get(resource.path)?.includes("pi-background")) return true;
|
|
1283
|
+
const reason = `pi-background cannot load in agent "${type}": managed child sessions do not support asynchronous model wakes after completion/worktree cleanup`;
|
|
1284
|
+
if (intentional) throw new Error(`${reason} (requested extension "${resource.path}").`);
|
|
1285
|
+
if (!warnedBackground) {
|
|
1286
|
+
warnedBackground = true;
|
|
1287
|
+
options.onToolActivity?.({ type: "end", toolName: `extension-error:${reason}; excluded from inherited resources` });
|
|
1288
|
+
}
|
|
1289
|
+
return false;
|
|
1290
|
+
};
|
|
1257
1291
|
const selectedExplicit = enabledExplicit.filter((resource) =>
|
|
1258
|
-
!
|
|
1259
|
-
const explicitPaths = new Set(selectedExplicit.map((resource) => resource.path));
|
|
1292
|
+
!resolvedNames.get(resource.path)?.some((name) => excludeNames.has(name)) && admitChildExtension(resource, true));
|
|
1293
|
+
const explicitPaths = new Set(selectedExplicit.map((resource) => extensionResourceIdentity(resource.path)));
|
|
1260
1294
|
// For priority, use the package's declared name rather than a generic entry
|
|
1261
1295
|
// alias like "src"; unrelated packages often have src/index.ts too.
|
|
1262
1296
|
const explicitNames = new Set(selectedExplicit.map((resource) =>
|
|
1263
|
-
|
|
1264
|
-
for (const source of
|
|
1265
|
-
if (
|
|
1297
|
+
resolved.packageName(resource) ?? extensionCanonicalName(resource.path)));
|
|
1298
|
+
for (const [source, resources] of explicitSources) {
|
|
1299
|
+
if (resources.some(resource => explicitPaths.has(extensionResourceIdentity(resource.path)))) {
|
|
1266
1300
|
resolvedExplicitNames.add(extensionCanonicalName(source));
|
|
1267
1301
|
}
|
|
1268
1302
|
}
|
|
@@ -1270,15 +1304,23 @@ export async function runAgent(
|
|
|
1270
1304
|
// The loader normally loads temporary paths before discovered paths. Keep
|
|
1271
1305
|
// that priority (including for directory/package paths with many entries).
|
|
1272
1306
|
...selectedExplicit.map((resource) => resource.path),
|
|
1273
|
-
...enabledConfigured.filter((
|
|
1274
|
-
const names = extensionCanonicalNames(path);
|
|
1307
|
+
...enabledConfigured.filter((resource) => {
|
|
1308
|
+
const names = resolvedNames.get(resource.path) ?? extensionCanonicalNames(resource.path);
|
|
1275
1309
|
if (names.some((name) => excludeNames.has(name))) return false;
|
|
1276
|
-
if (explicitPaths.has(
|
|
1277
|
-
|
|
1278
|
-
|
|
1310
|
+
if (explicitPaths.has(extensionResourceIdentity(resource.path)) ||
|
|
1311
|
+
explicitNames.has(resolved.packageName(resource) ?? extensionCanonicalName(resource.path))) return false;
|
|
1312
|
+
const intentional = names.some((name) => bareNames.has(name));
|
|
1313
|
+
return (loadAll || intentional) && admitChildExtension(resource, intentional);
|
|
1314
|
+
}).map((resource) => resource.path),
|
|
1279
1315
|
];
|
|
1280
|
-
|
|
1281
|
-
additionalExtensionPaths =
|
|
1316
|
+
const seen = new Set<string>();
|
|
1317
|
+
additionalExtensionPaths = selectedPaths.filter(path => {
|
|
1318
|
+
const identity = extensionResourceIdentity(path);
|
|
1319
|
+
if (seen.has(identity)) return false;
|
|
1320
|
+
seen.add(identity);
|
|
1321
|
+
return true;
|
|
1322
|
+
});
|
|
1323
|
+
prefilteredExtensionPaths = new Set(additionalExtensionPaths);
|
|
1282
1324
|
}
|
|
1283
1325
|
|
|
1284
1326
|
const loader = new DefaultResourceLoader({
|
|
@@ -1308,7 +1350,12 @@ export async function runAgent(
|
|
|
1308
1350
|
throw new Error(`Pi extension settings changed while loading agent "${type}"; retry with the current settings.`);
|
|
1309
1351
|
}
|
|
1310
1352
|
const loadedPaths = new Set(loader.getExtensions().extensions.map((extension) => extension.path));
|
|
1311
|
-
|
|
1353
|
+
// Preserve ordinary unrestricted inheritance: Pi may drop a faulty factory
|
|
1354
|
+
// or let a replaceable built-in yield to another discovered extension. The
|
|
1355
|
+
// original restrictive preselection still requires every selected path.
|
|
1356
|
+
const validateSelected = !loadAll || hasExcludes || !projectTrusted ||
|
|
1357
|
+
bareNames.size > 0 || (extensionsSpec?.paths.length ?? 0) > 0;
|
|
1358
|
+
for (const path of validateSelected ? prefilteredExtensionPaths : []) {
|
|
1312
1359
|
if (!loadedPaths.has(path)) {
|
|
1313
1360
|
const diagnostic = loader.getExtensions().errors.find((error) => error.path === path);
|
|
1314
1361
|
throw new Error(`Selected extension "${path}" was not loaded for agent "${type}"${diagnostic ? `: ${diagnostic.error}` : " (possibly replaced by another extension)"}.`);
|
|
@@ -1369,7 +1416,7 @@ export async function runAgent(
|
|
|
1369
1416
|
}
|
|
1370
1417
|
if (keepNames.size > 0 || extNames.size > 0) {
|
|
1371
1418
|
const survivingNames = new Set(
|
|
1372
|
-
loader.getExtensions().extensions.flatMap((e) => extensionCanonicalNames(e.path)),
|
|
1419
|
+
loader.getExtensions().extensions.flatMap((e) => resolvedNames.get(e.path) ?? extensionCanonicalNames(e.path)),
|
|
1373
1420
|
);
|
|
1374
1421
|
for (const name of keepNames) {
|
|
1375
1422
|
// A directory/package source can resolve to entries with different
|
|
@@ -1458,8 +1505,9 @@ export async function runAgent(
|
|
|
1458
1505
|
// only a human to answer, which `hasUI` decides. Isolation still suppresses
|
|
1459
1506
|
// it: an `isolated: true` agent is defined as built-in tools only.
|
|
1460
1507
|
const supervisorTools =
|
|
1461
|
-
ctx.hasUI && !isolated && options.supervisorQuestions !== false
|
|
1508
|
+
(ctx.hasUI || options.supervisorQuestions === "parent") && !isolated && options.supervisorQuestions !== false
|
|
1462
1509
|
? createSupervisorTool({
|
|
1510
|
+
...(options.supervisorQuestions === "parent" ? { parentQuestion: options.parentQuestion } : {}),
|
|
1463
1511
|
// Use the canonical agent type, not display_name: the question must
|
|
1464
1512
|
// identify the actual subagent that requested the decision.
|
|
1465
1513
|
agentLabel: agentConfig?.name ?? type,
|
|
@@ -1600,7 +1648,7 @@ export async function runAgent(
|
|
|
1600
1648
|
throwIfAborted(options.signal);
|
|
1601
1649
|
|
|
1602
1650
|
const inScope = noExtensions ? undefined : extensionToolNamesInScope(session, {
|
|
1603
|
-
loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames,
|
|
1651
|
+
loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames, resolvedNames,
|
|
1604
1652
|
});
|
|
1605
1653
|
checkToolCall = async (name, input, deadline) => {
|
|
1606
1654
|
const run = async (): Promise<ToolCallEventResult | undefined> => {
|