Compare commits
4
Commits
0423ec3676
...
27cbe965bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27cbe965bb | ||
|
|
0a061ae44b | ||
|
|
7a1043e5e0 | ||
|
|
d99dbce93d |
@@ -56,7 +56,7 @@
|
|||||||
},
|
},
|
||||||
"extensions/interactive-subagents": {
|
"extensions/interactive-subagents": {
|
||||||
"name": "@jbcode/pi-interactive-subagents",
|
"name": "@jbcode/pi-interactive-subagents",
|
||||||
"version": "3.7.3",
|
"version": "3.7.4",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@earendil-works/pi-coding-agent": "catalog:",
|
"@earendil-works/pi-coding-agent": "catalog:",
|
||||||
"@earendil-works/pi-tui": "catalog:",
|
"@earendil-works/pi-tui": "catalog:",
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
{
|
||||||
|
"incident": "multi_tool_use.parallel subagent completion did not wake idle orchestrator",
|
||||||
|
"observedAt": "2026-09-03T15:54:57-04:00",
|
||||||
|
"environment": {
|
||||||
|
"piVersion": "0.84.4",
|
||||||
|
"repositorySubagentPackageVersion": "0.1.0",
|
||||||
|
"note": "The failing tool surface was the harness-provided functions.subagent tool wrapped by multi_tool_use.parallel. It has not been proven to be implemented by the repository @jbcode/pi-subagents package.",
|
||||||
|
"parentSessionId": "01a06885-7f00-7cff-bc2f-334984151941",
|
||||||
|
"parentSessionFile": "/home/jb/.pi/agent/sessions/--srv-data-code-jb-pi-harness-tools--/2026-09-03T18-26-14-400Z_01a06885-7f00-7cff-bc2f-334984151941.jsonl",
|
||||||
|
"provider": "openai-codex",
|
||||||
|
"model": "gpt-5.6-sol",
|
||||||
|
"reasoning": "high"
|
||||||
|
},
|
||||||
|
"failingCall": {
|
||||||
|
"tool": "multi_tool_use.parallel",
|
||||||
|
"payload": {
|
||||||
|
"tool_uses": [
|
||||||
|
{
|
||||||
|
"recipient_name": "functions.subagent",
|
||||||
|
"parameters": {
|
||||||
|
"agent": "codebase-analyzer",
|
||||||
|
"name": "workflow-architecture",
|
||||||
|
"cwd": "/srv/data/code/jb-pi/harness-tools",
|
||||||
|
"task": "Read-only research. Analyze the current extensions/workflow and extensions/_workflows architecture end-to-end. Focus on what behavior is rigidly enforced, where failures/error-proneness arise, state/recovery mechanics, human gates, review gates, and extension complexity. Read relevant tests and docs. Return concrete findings with file paths and a proposed split of responsibilities between skills, minimal extensions, and ordinary repo instructions. Do not edit files."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"recipient_name": "functions.subagent",
|
||||||
|
"parameters": {
|
||||||
|
"agent": "precedent-locator",
|
||||||
|
"name": "workflow-history",
|
||||||
|
"cwd": "/srv/data/code/jb-pi/harness-tools",
|
||||||
|
"task": "Read-only research. Investigate git history and docs/ADR/workflow artifacts related to the workflow extension. Identify original goals, decisions, known usability issues, failure modes, later fixes, and lessons that should shape a replacement workflow. Return commits/docs/files and concise lessons. Do not edit files."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"recipient_name": "functions.subagent",
|
||||||
|
"parameters": {
|
||||||
|
"agent": "codebase-pattern-finder",
|
||||||
|
"name": "workflow-patterns",
|
||||||
|
"cwd": "/srv/data/code/jb-pi/harness-tools",
|
||||||
|
"task": "Read-only research. Find existing skills, extensions, tools, and agent instructions in this repo that already support planning, task tracking, subagent delegation, independent review, human gates, verification, worktrees, continuation, and recovery. Identify reusable pieces and missing capabilities. Return concrete paths and recommended composable patterns. Do not edit files."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"launchResults": [
|
||||||
|
{
|
||||||
|
"name": "workflow-architecture",
|
||||||
|
"result": "launched",
|
||||||
|
"childId": null,
|
||||||
|
"childSessionId": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "workflow-history",
|
||||||
|
"result": "launched",
|
||||||
|
"childId": null,
|
||||||
|
"childSessionId": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "workflow-patterns",
|
||||||
|
"result": "launched",
|
||||||
|
"childId": null,
|
||||||
|
"childSessionId": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expectedLifecycle": [
|
||||||
|
"wrapper returns one prompt acknowledgement per child without waiting",
|
||||||
|
"each child reaches one terminal state",
|
||||||
|
"each terminal result is queued exactly once for the parent",
|
||||||
|
"an idle parent is woken for each queued result",
|
||||||
|
"UI and tool-visible child state agree",
|
||||||
|
"late subagent_message resumes a completed child rather than steering a running child"
|
||||||
|
],
|
||||||
|
"observedLifecycle": [
|
||||||
|
"all three wrapped launches returned successful acknowledgements",
|
||||||
|
"the parent continued independent work and then became idle",
|
||||||
|
"the UI later appeared to show all three children completed",
|
||||||
|
"no terminal result woke the idle parent",
|
||||||
|
"the user manually woke the parent and reported completion",
|
||||||
|
"subagent_message for each child responded that the message was delivered to a running subagent"
|
||||||
|
],
|
||||||
|
"controlCase": {
|
||||||
|
"tool": "functions.subagent",
|
||||||
|
"wrapped": false,
|
||||||
|
"name": "skill-workflow-plan-review",
|
||||||
|
"cwd": "/srv/data/code/jb-pi/harness-tools/.worktrees/skill-led-development-workflow-plan",
|
||||||
|
"result": "automatic completion delivered",
|
||||||
|
"elapsed": "14m 9s"
|
||||||
|
},
|
||||||
|
"missingEvidence": [
|
||||||
|
"exact launch acknowledgement timestamps",
|
||||||
|
"internal child task IDs",
|
||||||
|
"internal child session IDs",
|
||||||
|
"terminal-state timestamps",
|
||||||
|
"callback queue entries"
|
||||||
|
],
|
||||||
|
"reproductionRequirement": "A focused fixture must capture the missing internal evidence from instrumented execution rather than infer it from UI state."
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ After configuring the same scope and token in the npm configuration visible to P
|
|||||||
pi install npm:@jbcode/pi-dictate@0.2.0
|
pi install npm:@jbcode/pi-dictate@0.2.0
|
||||||
pi install npm:@jbcode/pi-effort-controls@0.1.0
|
pi install npm:@jbcode/pi-effort-controls@0.1.0
|
||||||
pi install npm:@jbcode/pi-prompt-snippets@0.1.0
|
pi install npm:@jbcode/pi-prompt-snippets@0.1.0
|
||||||
pi install npm:@jbcode/pi-interactive-subagents@3.7.3
|
pi install npm:@jbcode/pi-interactive-subagents@3.7.4
|
||||||
pi install npm:@jbcode/pi-todo-herdr@0.2.5
|
pi install npm:@jbcode/pi-todo-herdr@0.2.5
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -4,6 +4,10 @@
|
|||||||
|
|
||||||
During the Orca polling fix on 02SEP2026, Bash tool calls configured with `cwd` beneath `/srv/data/code/jb-pi/harness-tools/.worktrees/fix-orca-polling` ran from the primary checkout instead. A `pwd` probe reproduced the mismatch, and later install and commit commands targeted the primary checkout until they were rerun with an explicit `cd` or `git -C`. The mistaken commit was reset immediately without discarding the pre-existing working changes. Reproduce this with a disposable hidden worktree and require the tool to either honor the requested directory or fail before executing the command. Cover both workspace and nested package directories; keep explicit `cd` and `git -C` as the workaround until the regression is fixed.
|
During the Orca polling fix on 02SEP2026, Bash tool calls configured with `cwd` beneath `/srv/data/code/jb-pi/harness-tools/.worktrees/fix-orca-polling` ran from the primary checkout instead. A `pwd` probe reproduced the mismatch, and later install and commit commands targeted the primary checkout until they were rerun with an explicit `cd` or `git -C`. The mistaken commit was reset immediately without discarding the pre-existing working changes. Reproduce this with a disposable hidden worktree and require the tool to either honor the requested directory or fail before executing the command. Cover both workspace and nested package directories; keep explicit `cd` and `git -C` as the workaround until the regression is fixed.
|
||||||
|
|
||||||
|
## Canonicalize symlinked Pi npm install roots
|
||||||
|
|
||||||
|
Publishing `@jbcode/pi-interactive-subagents@3.7.4` exposed a Pi package-manager failure when `~/.pi` resolves through `/home/jb/data/.home-dotdirs/.pi`. Pi 0.84.4 invoked npm 12.0.2 with `--prefix /home/jb/.pi/agent/npm` from the repository cwd. npm rewrote the lockfile root as `file:../../../data/.home-dotdirs/.pi/agent/npm`, recursively duplicated that path until the lock contained 12,576 entries and 18 MB of data, and then failed with `ENOENT` during reification. Canonicalizing the npm root, regenerating both lockfiles from that directory, and installing once restored the 124-entry lock and all configured packages. Update Pi's npm package execution to use the canonical install root as its cwd, add a symlinked-agent-directory regression test, and ensure interrupted installs terminate their package-manager children before another reconciliation begins.
|
||||||
|
|
||||||
## Merge `_workflows` reference runtime into `workflow`
|
## Merge `_workflows` reference runtime into `workflow`
|
||||||
|
|
||||||
`extensions/_workflows` was retained only as a reference while `extensions/workflow` was developed, but `workflow` still imports its runner, model, prompt, and serialization modules. Move the required code under `extensions/workflow`, remove obsolete model-authored workflow surfaces, update tests and documentation, and then delete the reference directory. Until that work is complete, `@jbcode/pi-workflow` must remain a private Bun workspace and `extensions/_workflows` must remain excluded from workspaces and publication. Acceptance requires `bun pm pack` for `workflow` to contain every runtime import without reaching outside the package root, followed by an isolated Pi package smoke test.
|
`extensions/_workflows` was retained only as a reference while `extensions/workflow` was developed, but `workflow` still imports its runner, model, prompt, and serialization modules. Move the required code under `extensions/workflow`, remove obsolete model-authored workflow surfaces, update tests and documentation, and then delete the reference directory. Until that work is complete, `@jbcode/pi-workflow` must remain a private Bun workspace and `extensions/_workflows` must remain excluded from workspaces and publication. Acceptance requires `bun pm pack` for `workflow` to contain every runtime import without reaching outside the package root, followed by an isolated Pi package smoke test.
|
||||||
@@ -20,9 +24,9 @@ Slice 1 found two Nono 0.74 limitations while building behavioral policy proofs.
|
|||||||
|
|
||||||
The strict `workflow` extension repeatedly failed before implementation because planner output and persisted state-machine constraints were too brittle for adaptive subagent orchestration. In a separate session, design a repository-managed skill where the main Pi agent owns the plan, task tree, isolated worktree, explicit user approval gates, verification, and independent review while launching standard `scout` and `worker` subagents directly. Preserve file-ownership boundaries, no inferred approvals, review independence, and resumable repository evidence, but prefer native Pi primitives over a custom orchestration engine. Evaluate a small committed state manifest for crash recovery before retiring or archiving the extension. Use the abandoned `.worktrees/workflow-plan-seed` implementation only as reference; do not integrate it by default.
|
The strict `workflow` extension repeatedly failed before implementation because planner output and persisted state-machine constraints were too brittle for adaptive subagent orchestration. In a separate session, design a repository-managed skill where the main Pi agent owns the plan, task tree, isolated worktree, explicit user approval gates, verification, and independent review while launching standard `scout` and `worker` subagents directly. Preserve file-ownership boundaries, no inferred approvals, review independence, and resumable repository evidence, but prefer native Pi primitives over a custom orchestration engine. Evaluate a small committed state manifest for crash recovery before retiring or archiving the extension. Use the abandoned `.worktrees/workflow-plan-seed` implementation only as reference; do not integrate it by default.
|
||||||
|
|
||||||
## Support subagent launches through `multi_tool_use.parallel`
|
## Support finished-session resume for Claude CLI subagents
|
||||||
|
|
||||||
A user reported that launching the repository's subagent plugin through Pi's `multi_tool_use.parallel` wrapper does not work, although equivalent launches succeed outside the wrapper in another session. Reproduce with two minimal independent subagent launches, comparing direct tool calls with wrapped calls. Capture whether failure occurs during schema validation, dispatch, asynchronous acknowledgement, task registration, or completion delivery. The wrapper must return promptly for fire-and-forget launches, preserve each subagent's name and working directory, and allow the existing automatic completion notification to deliver each result exactly once without polling. Add focused integration coverage for successful parallel launch, one-child failure, cancellation, and duplicate-delivery prevention before changing either tool.
|
`subagent_message` can steer a running Claude CLI pane, but a completed Claude launch has no Pi session file and cannot use the existing finished-session resume path. Store the Claude session identifier and original sandbox metadata in the parent registry, then resume with Claude's native session mechanism while retaining the original name and fire-and-forget result delivery. Until implemented, keep the README's finished-session contract explicitly limited to Pi-backed subagents.
|
||||||
|
|
||||||
## Investigate transient concurrent NeMo benchmark failures
|
## Investigate transient concurrent NeMo benchmark failures
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
# Skill-led development workflow plan
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
|
||||||
|
**Date:** 03SEP2026
|
||||||
|
**Working skill name:** `develop-workflow`
|
||||||
|
|
||||||
|
## Decision summary
|
||||||
|
|
||||||
|
Replace the custom workflow engine with a model-invoked workflow skill that composes Pi's existing tools and repository evidence. Do not build a new extension in the first version.
|
||||||
|
|
||||||
|
The workflow activates by default for feature work and other multi-step code changes, while questions and trivial edits stay lightweight. The parent Pi agent owns orchestration and judgment. It uses standard subagents for bounded assignments, `ask_user` for explicit human decisions, todo-herdr for session progress, Git worktrees for isolation, repository Markdown for recovery, project commands for verification, and independent subagents for review.
|
||||||
|
|
||||||
|
Full-path work always pauses for:
|
||||||
|
|
||||||
|
1. Implementation-plan approval before production edits.
|
||||||
|
2. Direction approval before implementing non-trivial UI work.
|
||||||
|
3. Risk approval immediately before a risky or destructive action.
|
||||||
|
|
||||||
|
Light-path work skips plan, UI, and independent-review gates. Risk gates apply to every path; discovering meaningful risk promotes the change to the full path before editing.
|
||||||
|
|
||||||
|
After plan approval, it proceeds automatically through implementation, verification, independent review, remediation, and local integration unless it encounters a gate or cannot prove a completion criterion.
|
||||||
|
|
||||||
|
Workflow records remain in project history. Successful work is committed and integrated locally; pushing and production deployment remain separate user actions unless explicitly requested.
|
||||||
|
|
||||||
|
A skill can make these gates explicit and testable, but cannot technically prevent an agent from skipping them. The first version therefore offers procedural guardrails, not sandbox enforcement. Pilot evaluation must measure gate compliance directly. Reconsider a narrow enforcement mechanism only if repeated real failures remain after tightening the skill.
|
||||||
|
|
||||||
|
## Why replace the extension
|
||||||
|
|
||||||
|
The current extension encoded useful policy, but coupled policy to a large orchestration runtime. `extensions/workflow` and `extensions/_workflows` contain roughly 30,000 lines of TypeScript and JavaScript. `extensions/workflow/engine.ts` alone is nearly 5,000 lines. The initial implementation added more than 20,000 lines, followed by a usability and recovery change exceeding 4,000 added lines.
|
||||||
|
|
||||||
|
The documented failure evidence is more important than size:
|
||||||
|
|
||||||
|
- Run `512d6c31-5bde-4e93-8164-5d31fb684b00` failed before implementation because design prose described prototyping but omitted the exact prototype assignment required by the state machine.
|
||||||
|
- Run `51b0c1cb-f5b2-47f9-8c0c-b323307b720b` was interrupted during discovery.
|
||||||
|
- Run `ecb11e1e-fe38-4076-862e-739498dbe33b` stalled in design until interrupted.
|
||||||
|
- Recovery, leases, context handoffs, notification delivery, profile adaptation, restricted script execution, custom TUI surfaces, and exact structured outputs created many interacting failure paths unrelated to producing correct code.
|
||||||
|
|
||||||
|
The replacement keeps the policies that matter and removes the custom execution engine that made the policy brittle.
|
||||||
|
|
||||||
|
## Design principles
|
||||||
|
|
||||||
|
### Evidence over ceremony
|
||||||
|
|
||||||
|
Require observable evidence at each boundary: an approved plan, a real diff, actual command results, target-interface evidence for UI, and independent review findings. Do not require an exact model-authored graph or schema merely to advance.
|
||||||
|
|
||||||
|
### Fail closed at gates, adapt within phases
|
||||||
|
|
||||||
|
Human approval is categorical and must never be inferred. Inside an approved phase, let the parent agent adapt task order, retry a failed check, narrow scope, or reassign work without satisfying a custom state-machine transition.
|
||||||
|
|
||||||
|
### One orchestrator
|
||||||
|
|
||||||
|
The parent Pi agent owns the workflow record, task tree, worktree, decisions, integration, and final report. Subagents receive bounded assignments and return evidence. They do not own workflow state, ask the user, integrate branches, or delegate mutation. A worker may use its available read-only scout or researcher children within its assigned boundary, but the parent remains responsible for their results and known delivery failures.
|
||||||
|
|
||||||
|
### Repository state is the recovery boundary
|
||||||
|
|
||||||
|
Session state improves usability but is not authoritative after interruption. Git, the retained workflow record, the approved plan, and generated artifacts provide enough evidence for a new session to reconstruct the next safe action.
|
||||||
|
|
||||||
|
### Independence is identity-based
|
||||||
|
|
||||||
|
An agent that authored or mutated a reviewed diff cannot review it. Record mutator and reviewer display names plus stable task or child-session IDs when the tool exposes them. If stable IDs are unavailable, treat the entire ambiguous profile/run as a mutator and exclude it. A reviewer stays read-only and never remediates its own findings. The original implementer or another non-reviewer performs remediation, followed by a fresh review.
|
||||||
|
|
||||||
|
### Automation must remain reversible
|
||||||
|
|
||||||
|
Automate research, implementation, tests, review, remediation, commits, rebases, and clean local fast-forward integration. Stop when the next action is ambiguous, destructive, production-facing, or cannot be reversed safely.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. Model-invoked `develop-workflow` skill
|
||||||
|
|
||||||
|
Create `skills/develop/develop-workflow/SKILL.md` with a precise description that triggers on feature work and non-trivial bug fixes, refactors, migrations, and UI changes. Explicitly exclude questions, research-only requests, and trivial edits.
|
||||||
|
|
||||||
|
Keep the main skill short and sequential. Put branch-specific detail behind pointers:
|
||||||
|
|
||||||
|
```text
|
||||||
|
skills/develop/develop-workflow/
|
||||||
|
├── SKILL.md
|
||||||
|
├── references/
|
||||||
|
│ ├── evidence-and-recovery.md
|
||||||
|
│ ├── git-and-integration.md
|
||||||
|
│ └── review-and-remediation.md
|
||||||
|
├── assets/
|
||||||
|
│ └── workflow-record.md
|
||||||
|
└── scripts/
|
||||||
|
└── checkpoint-record.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
`SKILL.md` owns phase order, gates, and completion criteria. References own detailed recovery, Git, and review rules. The asset is the default retained workflow-record template. The dependency-free checkpoint script validates required headings, writes through a same-directory temporary file, flushes, and atomically renames it; it does not orchestrate phases or make decisions.
|
||||||
|
|
||||||
|
### 2. Existing Pi primitives
|
||||||
|
|
||||||
|
Reuse rather than wrap:
|
||||||
|
|
||||||
|
- `ask_user`: explicit requirements, plan, UI, and risk decisions.
|
||||||
|
- `set_tasks`, `patch_tasks`, and `list_task`: session-local execution progress and post-compaction recovery.
|
||||||
|
- `subagent` and `subagent_message`: fresh, bounded research, implementation, verification, and review contexts.
|
||||||
|
- `code-review`: parallel specification and repository-standards review.
|
||||||
|
- `prototype` and `browser-harness`: non-trivial UI alternatives and real application validation.
|
||||||
|
- `tdd`, `diagnosing-bugs`, `code-research`, and project-specific skills: phase-specific procedures.
|
||||||
|
- `artifact-presenter`: plan and long-artifact review.
|
||||||
|
- Git worktrees and normal project commands: isolation, commits, verification, rebase, and integration.
|
||||||
|
|
||||||
|
Known harness defects are workflow constraints, not footnotes. Until fixed, launch subagents directly rather than through `multi_tool_use.parallel`; never park the parent solely on an expected callback; and verify that each result was delivered. For shell work inside hidden worktrees, use explicit `cd <worktree> && ...` or `git -C <worktree> ...`, then verify `pwd` or Git's top-level path before mutation because the Bash tool has previously ignored its configured working directory.
|
||||||
|
|
||||||
|
Before starting the full path, preflight required capabilities. Git worktree operations, a writable retained record, an explicit human interaction channel, target-interface validation when applicable, and a Git-capable independent reviewer are mandatory. Todo-herdr and artifact-presenter may degrade to the retained record and a reported artifact path, but their absence cannot bypass a gate. In print or JSON mode, or whenever a mandatory gate or validator cannot run, emit the pending decision or validation instructions and stop before product mutation.
|
||||||
|
|
||||||
|
### 3. Retained workflow record
|
||||||
|
|
||||||
|
Use the repository's established plan location. Fall back to `docs/plans/<feature-branch-slug>.md` when no convention exists. Require the path to be unique before creation. Keep one concise Markdown record per active workflow and retain it after completion.
|
||||||
|
|
||||||
|
The record contains:
|
||||||
|
|
||||||
|
- status and current phase;
|
||||||
|
- request, scope, and out-of-scope items;
|
||||||
|
- source branch, base commit, feature branch, and worktree;
|
||||||
|
- acceptance criteria;
|
||||||
|
- approved implementation plan and explicit gate decisions;
|
||||||
|
- mutating assignment ownership;
|
||||||
|
- verification matrix and actual command outcomes;
|
||||||
|
- review scope, reviewer identities, findings, and remediation outcome;
|
||||||
|
- integration result;
|
||||||
|
- blockers and the exact next safe action.
|
||||||
|
|
||||||
|
Do not store transcripts, process leases, heartbeats, notification receipts, complete command output, or a model-authored execution graph. Link to durable artifacts or commits instead.
|
||||||
|
|
||||||
|
Update the record only at meaningful checkpoints through the atomic checkpoint script. Commit the initial record before any product mutation and commit every approved human-gate decision before dependent product mutation. The last committed record is the durable fallback if a process stops during a later update. Todo-herdr remains the high-frequency progress view.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Phase 0: Classify
|
||||||
|
|
||||||
|
Choose the smallest sufficient path.
|
||||||
|
|
||||||
|
- **Answer path:** questions and read-only explanations. Make no changes.
|
||||||
|
- **Light path:** one obvious, low-risk edit with at most two distinct steps. Inspect, edit, run focused checks, and summarize without plan, UI, or independent-review gates. Promote to the full path before editing if discovery exposes meaningful risk or scope.
|
||||||
|
- **Full path:** feature work or any change with three or more steps, ambiguity, multiple files or modules, architectural impact, non-trivial UI, dependencies, migration, security implications, or meaningful regression risk.
|
||||||
|
|
||||||
|
When uncertain, use the full path. Record why the light path is safe when a change is near the boundary.
|
||||||
|
|
||||||
|
**Completion criterion:** the selected path matches the observed scope and risk.
|
||||||
|
|
||||||
|
### Phase 1: Establish isolation and evidence
|
||||||
|
|
||||||
|
1. Read repository instructions, relevant docs, ADRs, tests, and current Git state.
|
||||||
|
2. Record the source branch and exact base commit.
|
||||||
|
3. Create `./.worktrees/<feature>` on a descriptive feature branch before feature edits.
|
||||||
|
4. Prove every subsequent shell command's location with explicit `cd` or `git -C`; do not rely on the Bash tool's configured `cwd` until its tracked defect is fixed.
|
||||||
|
5. If the source checkout is dirty, explain exactly which changes the new worktree omits and ask before proceeding.
|
||||||
|
6. Atomically create the unique workflow record in the worktree and create a task tree in the session.
|
||||||
|
7. Run applicable documentation checks, then commit the initial record as a pre-mutation recovery checkpoint.
|
||||||
|
|
||||||
|
Treat branch collisions, record collisions, unmerged paths, unexpected worktree ownership, or an unignored worktree directory as blockers. Do not silently rename, reset, clean, stash, or copy source changes.
|
||||||
|
|
||||||
|
**Completion criterion:** the isolated worktree identity is proven, the initial record is committed, and the record names the next action.
|
||||||
|
|
||||||
|
### Phase 2: Discover and resolve requirements
|
||||||
|
|
||||||
|
Use read-only subagents in parallel when they can investigate independent concerns. Typical lanes are architecture/data flow, tests and precedents, and integration/blast radius. Give every child a self-contained prompt and state that no files are owned because the work is read-only.
|
||||||
|
|
||||||
|
The parent reconciles evidence, identifies contradictions and unknowns, and uses `ask_user` only for decisions that repository evidence cannot answer. Persist answers in the workflow record.
|
||||||
|
|
||||||
|
**Completion criterion:** requirements, constraints, acceptance criteria, affected surfaces, risks, and unresolved questions are explicit.
|
||||||
|
|
||||||
|
### Phase 3: Design and approve
|
||||||
|
|
||||||
|
Draft an implementation plan that includes:
|
||||||
|
|
||||||
|
- acceptance criteria mapped to implementation slices;
|
||||||
|
- concrete file or directory ownership for every mutating assignment;
|
||||||
|
- existing seams and patterns to reuse;
|
||||||
|
- focused and full verification commands;
|
||||||
|
- realistic test-data requirements;
|
||||||
|
- UI-impact classification;
|
||||||
|
- dependencies, migrations, production effects, and other risk gates;
|
||||||
|
- the review fixed point and specification source;
|
||||||
|
- integration and rollback strategy.
|
||||||
|
|
||||||
|
Present the complete plan through artifact-presenter. Use `ask_user` to approve or request revision. Record the decision. Production edits cannot begin before explicit approval. If prototyping or later evidence materially revises scope, acceptance criteria, ownership, verification, or architecture, present the revised plan and obtain approval again before production edits continue.
|
||||||
|
|
||||||
|
**Completion criterion:** the user explicitly approved the exact recorded plan.
|
||||||
|
|
||||||
|
### Phase 4: Prototype non-trivial UI
|
||||||
|
|
||||||
|
For non-trivial UI, create three structurally different static or throwaway variants before editing production components. If three meaningful alternatives are impossible, ask the user to approve a smaller explicit count rather than failing on a schema. Prefer realistic host context and data. Validate each candidate in its actual interface: browser-harness for web UI, a real PTY/application session for TUI or native surfaces, and representative responsive sizes where the interface supports them.
|
||||||
|
|
||||||
|
Follow the existing prototype skill's throwaway policy: capture all candidates on a dedicated throwaway branch, retain its commit reference in the workflow record, and keep prototype-only routes, switchers, and losing candidates out of the product branch. Present the candidates and use `ask_user` to select or refine a direction. Record and checkpoint the accepted direction before production mutation. Do not promote prototype code directly as production code. Treat the direction decision as approval only for the recorded UI choice; return to plan approval for any other material plan revision.
|
||||||
|
|
||||||
|
Minor or non-visual UI changes may skip this phase only when the record states why.
|
||||||
|
|
||||||
|
**Completion criterion:** the user approved the required number of target-interface-validated candidates, the throwaway source is retained by commit, and the production plan reflects the selected direction.
|
||||||
|
|
||||||
|
### Phase 5: Implement in slices
|
||||||
|
|
||||||
|
The parent assigns each mutating slice to one implementer with exclusive file ownership. The parent itself counts as an implementer whenever it edits. State ownership before parallel work begins. Serialize mutation by default; parallelize only when ownership is disjoint and integration risk is demonstrably low.
|
||||||
|
|
||||||
|
Each implementer receives the approved plan, relevant decisions, owned paths, acceptance criteria, test seam, commands it may run, and an explicit assignment deadline recorded in the workflow record. Default mutating assignments to 15 minutes; choose and approve a different bound in the plan when the task shape requires it. Instruct workers to stop at the deadline and not to commit, integrate, or delegate mutation. Use TDD at pre-agreed seams where practical. Run focused tests after each behavioral slice. Keep unrelated cleanup out of scope.
|
||||||
|
|
||||||
|
Because the standard worker profile technically permits shell commands, commits, and read-only nested subagents, capture branch, `HEAD`, status, changed paths, and a patch artifact before and after every mutating assignment. The parent rejects unexpected commits or out-of-ownership paths and recovers from observed Git state rather than trusting prompt compliance.
|
||||||
|
|
||||||
|
On an ownership or Git-invariant failure, stop all mutation, preserve the before/after evidence and worker result, mark that worker identity as a mutator ineligible for review, and open an explicit recovery decision with inspect, accept-and-replan, restore-with-a-non-destructive-patch, or abort choices. Resume only after the user selects an evidence-backed action.
|
||||||
|
|
||||||
|
A deadline or missing terminal delivery never triggers automatic retry. Ask the child to stop through the available control surface, freeze the worktree, and prove the child process/session reached a terminal state before reassignment. Use the subagent UI or status/cancel tools when available. If liveness cannot be proven after parent interruption, keep the workflow blocked for human termination or takeover; do not launch another mutating agent into that worktree.
|
||||||
|
|
||||||
|
The parent inspects returned diffs and evidence before marking a slice complete. A subagent's success claim is not proof.
|
||||||
|
|
||||||
|
**Completion criterion:** every acceptance criterion has corresponding code and test evidence, and all changed paths are owned and intentional.
|
||||||
|
|
||||||
|
### Phase 6: Verify the integrated change
|
||||||
|
|
||||||
|
Run verification from the worktree, not from a child transcript:
|
||||||
|
|
||||||
|
1. Inspect the complete diff and Git status.
|
||||||
|
2. Run focused behavioral tests.
|
||||||
|
3. Run typecheck, lint, build, and the full test suite required by the repository.
|
||||||
|
4. Exercise E2E behavior with realistic data when changed behavior crosses process or UI boundaries.
|
||||||
|
5. Validate UI in the browser/application at required responsive sizes.
|
||||||
|
6. Update documentation affected by behavior.
|
||||||
|
7. Record exact commands, exit codes, and concise outcomes.
|
||||||
|
|
||||||
|
Never report a command successful unless it actually ran to completion. A flaky or environmental failure remains visible until explained and resolved or escalated.
|
||||||
|
|
||||||
|
**Completion criterion:** the approved verification matrix passes with recorded evidence.
|
||||||
|
|
||||||
|
### Phase 7: Commit, synchronize, and independently review
|
||||||
|
|
||||||
|
Create focused commits only after their appropriate checks pass. Before final review:
|
||||||
|
|
||||||
|
1. Fetch the latest source branch when a remote exists.
|
||||||
|
2. Rebase the feature branch onto the latest source branch.
|
||||||
|
3. If the rebase changes the effective tree or requires conflict resolution, rerun the affected checks and full required verification.
|
||||||
|
4. Pin the review base, product `HEAD`, clean status, tree hash, commit list, and diff fingerprint in a parent-generated review brief retained inside the workflow record.
|
||||||
|
5. Generate the exact review bytes with `git diff --binary --full-index --find-renames <base>...<product-head> --` and hash those bytes with SHA-256. Record the command and digest. The integrated base and product commits are the durable artifact; regenerate the bytes from those commits instead of committing a duplicate patch.
|
||||||
|
|
||||||
|
Launch specification and standards reviewers as separate direct subagent calls using profiles that have repository read access plus Git-capable `bash`, but no `edit` or `write` tool. A scout without Git access is not an eligible reviewer. Supply the same review brief, approved plan or workflow record as the specification source, repository standards, and exact diff command. The workflow record is a valid specification even when `docs/agents/issue-tracker.md` is absent; update the existing `code-review` skill so this path does not require issue-tracker configuration. Reviewers independently regenerate the pinned diff, confirm its SHA-256 digest, remain read-only, and differ from every recorded mutator identity. Capture Git status and tree state before and after review; any reviewer mutation invalidates its verdict and marks that identity as a mutator.
|
||||||
|
|
||||||
|
For findings:
|
||||||
|
|
||||||
|
- bind each finding to evidence and severity;
|
||||||
|
- send valid fixes to an implementer, never the reviewer;
|
||||||
|
- rerun affected checks and the full required suite;
|
||||||
|
- run another fresh independent review;
|
||||||
|
- escalate to the user after two unsuccessful remediation cycles or when a finding requires scope or architecture changes.
|
||||||
|
|
||||||
|
Bind approval to the reviewed base, product `HEAD`, clean status, tree hash, and diff fingerprint. A later metadata-only workflow-record closure commit is outside the product-review fingerprint and must be proven to touch only that record.
|
||||||
|
|
||||||
|
**Completion criterion:** both review axes approve the exact verified product diff.
|
||||||
|
|
||||||
|
### Phase 8: Integrate and close
|
||||||
|
|
||||||
|
Integrate locally without another routine human pause when all prior evidence remains valid.
|
||||||
|
|
||||||
|
1. Confirm the source branch still points to the commit used for the final rebase.
|
||||||
|
2. If it advanced, return to synchronize, verify, and review.
|
||||||
|
3. Confirm the source checkout is clean and safe to update. A dirty or missing checkout blocks automatic integration.
|
||||||
|
4. Fast-forward the source branch to the reviewed feature branch. Do not create a merge commit.
|
||||||
|
5. In the source checkout, update only the workflow record with the bound review and completed integration evidence. Prove the closure diff touches no product file, run applicable documentation checks, and create one metadata-only closure commit.
|
||||||
|
6. Run a final cheap sanity check appropriate to the project.
|
||||||
|
7. Remove the completed worktree, then delete the feature branch only after proving both the reviewed product commit and closure commit are reachable from the source branch.
|
||||||
|
8. Mark all tasks complete and report the reviewed product commit, metadata closure commit, review command and SHA-256 digest, reviewer identities, workflow-record path, and verification evidence.
|
||||||
|
|
||||||
|
Ask before conflict resolution that changes approved behavior, force operations, destructive cleanup, pushing, publishing, deployment, production access, or any non-fast-forward integration.
|
||||||
|
|
||||||
|
**Completion criterion:** the reviewed commit is reachable from the intended local source branch, the retained record is complete, and temporary worktree state is removed safely.
|
||||||
|
|
||||||
|
## Recovery procedure
|
||||||
|
|
||||||
|
A fresh session must not trust the workflow record blindly. Reconstruct reality in this order:
|
||||||
|
|
||||||
|
1. Read repository instructions, then inspect worktrees, branches, current refs, status, commits, and diff.
|
||||||
|
2. Discover candidate workflow records from observed branches/worktrees and repository conventions; do not assume the current checkout contains the active record. A workflow-created branch/worktree without a record is an orphan, not disposable state.
|
||||||
|
3. For an orphan, preserve the worktree, inspect its branch, base, reflog, status, commits, and diff, then ask whether to adopt it by creating an initial record, leave it quarantined, or remove it after separate cleanup approval. Never delete an orphan automatically.
|
||||||
|
4. Read a discovered record and compare its base, branch, and last completed checkpoint with observed Git evidence.
|
||||||
|
5. If an atomic-update temporary file remains, preserve it for inspection and use the last committed valid record as authoritative until the user approves recovery.
|
||||||
|
6. Rebuild todo-herdr tasks from completed evidence and the recorded next action.
|
||||||
|
7. Treat interrupted mutating assignments as unknown outcomes. Freeze mutation, inspect their diff, and prove the prior child is terminal before rerunning or reassigning work.
|
||||||
|
8. Treat prior subagents as gone only after terminal state is proven. A completed UI indicator without a delivered result is not evidence of either output or liveness.
|
||||||
|
9. Resume from the last proven checkpoint, not the most optimistic status line.
|
||||||
|
10. Ask the user when evidence conflicts, approved scope may have changed, child liveness is unknown, or recovery would require a risky action.
|
||||||
|
|
||||||
|
The workflow record is a recovery map, not a transaction log.
|
||||||
|
|
||||||
|
## Risk gates
|
||||||
|
|
||||||
|
Ask just in time, with concrete evidence and a recommended option, before:
|
||||||
|
|
||||||
|
- adding, removing, or materially upgrading dependencies;
|
||||||
|
- schema or data migration;
|
||||||
|
- production or live-database access;
|
||||||
|
- public API or compatibility breaks;
|
||||||
|
- destructive Git or filesystem operations, except narrowly pre-authorized cleanup of the workflow's own worktree and feature branch after the reachability and clean-state checks in Phase 8 pass;
|
||||||
|
- force push, history rewrite of shared work, or non-fast-forward integration;
|
||||||
|
- conflict resolution that changes approved behavior;
|
||||||
|
- materially expanded scope or acceptance criteria;
|
||||||
|
- security posture changes;
|
||||||
|
- publishing or deployment.
|
||||||
|
|
||||||
|
Record the decision and its scope. One approval does not authorize later, broader risk.
|
||||||
|
|
||||||
|
## What the first version deliberately removes
|
||||||
|
|
||||||
|
- Custom workflow state machine and transition engine.
|
||||||
|
- Custom agent-profile loader and planner-selection schema.
|
||||||
|
- Custom child-session runtime, scheduler, leases, and heartbeat recovery.
|
||||||
|
- Model-authored JavaScript workflow DSL.
|
||||||
|
- Custom dashboard, notification outbox, and context-handoff engine.
|
||||||
|
- Exact structured assignment graphs required before implementation.
|
||||||
|
- Automatic replay of interrupted mutating work.
|
||||||
|
- A new workflow extension of any kind.
|
||||||
|
|
||||||
|
Keep the current extension as historical reference during the pilot, but do not expose it as the default workflow.
|
||||||
|
|
||||||
|
## Implementation phases
|
||||||
|
|
||||||
|
### Slice 1: Skill and record contract
|
||||||
|
|
||||||
|
- Add `skills/develop/develop-workflow/` with the progressive-disclosure structure above.
|
||||||
|
- Write a compact workflow-record template and dependency-free atomic checkpoint script.
|
||||||
|
- Add focused `node:test` coverage for valid creation and replacement, malformed or missing required sections, interrupted writes preserving the prior record, stale temporary files, concurrent or stale updates, flush/rename failure, and cleanup that never discards the last valid checkpoint.
|
||||||
|
- Add a concise ADR superseding the custom-engine decision while preserving human gates, isolation, verification, and reviewer independence.
|
||||||
|
- Audit every old-workflow reference with repository search. At minimum, add explicit `superseded-by` links or historical banners to `docs/workflow/README.md`, `docs/workflow/design.md`, `docs/workflow/dashboard-mocks.md`, the workflow and package ADRs, `README.md`, `extensions/workflow/README.md`, and `docs/nono-dynamic-permissions-plan.md`; resolve the replacement TODO in `docs/todos.md` only after the pilot succeeds.
|
||||||
|
- Ensure managed Pi skill links expose the skill without exposing the old workflow extension.
|
||||||
|
|
||||||
|
**Success criteria:** Pi discovers the skill; its description selects full versus light work correctly in fresh sessions; `node --test skills/develop/develop-workflow/tests/checkpoint-record.test.mjs` passes every failure-path case; the initial and post-gate records are atomic and committed before dependent product mutation; and every stale document points explicitly to the superseding ADR.
|
||||||
|
|
||||||
|
### Slice 2: Review and recovery integration
|
||||||
|
|
||||||
|
- Align `develop-workflow` with `code-review`, `prototype`, `artifact-presenter`, todo-herdr, and direct subagent contracts.
|
||||||
|
- Update `code-review` to accept the workflow record as its specification without an issue-tracker prerequisite.
|
||||||
|
- Add the exact reviewer-independence, remediation, parent-generated review brief, record fingerprint, recovery, and local integration procedures.
|
||||||
|
- Remove contradictory instructions from `implement` or replace it with a pointer to the new workflow where appropriate.
|
||||||
|
|
||||||
|
**Success criteria:** no skill tells an implementer to self-review; a fresh session can resume a fixture using only repository evidence; reviewers cannot become remediators.
|
||||||
|
|
||||||
|
### Slice 3: Scenario evaluation
|
||||||
|
|
||||||
|
Run the skill in fresh Pi sessions against disposable repositories or worktrees:
|
||||||
|
|
||||||
|
1. A question and a trivial edit that must avoid full-workflow ceremony.
|
||||||
|
2. An ambiguous multi-file feature that must research and ask before planning.
|
||||||
|
3. Non-trivial web and TUI changes that must prototype, validate in the target interface, and wait for human selection.
|
||||||
|
4. A dependency or migration change that must open a just-in-time risk gate.
|
||||||
|
5. A failed test, expired assignment, missing terminal delivery, and interrupted implementer that must freeze until diff and child liveness are proven.
|
||||||
|
6. Review findings that must be remediated by a non-reviewer and reviewed again.
|
||||||
|
7. A session restart that must reconstruct tasks from the retained record.
|
||||||
|
8. A source branch advancing before integration that must force rebase, verification, and review again.
|
||||||
|
9. Hidden-worktree shell execution that must prove its actual cwd before mutation.
|
||||||
|
10. Direct and wrapped subagent completion where missing delivery must remain visible and block dependent progress.
|
||||||
|
11. A crash after worktree creation but before record commit that must preserve and explicitly adopt, quarantine, or approve removal of the orphan.
|
||||||
|
12. Print/JSON or missing-tool execution that must emit pending instructions and stop before product mutation.
|
||||||
|
|
||||||
|
Capture omissions and friction in `docs/todos.md` as required by this repository.
|
||||||
|
|
||||||
|
**Success criteria:** all twelve scenarios reach the correct gate, blocker, or completion state without custom workflow code, and no scenario claims unexecuted verification, undelivered subagent evidence, or unknown child liveness as complete.
|
||||||
|
|
||||||
|
### Slice 4: Pilot and retirement decision
|
||||||
|
|
||||||
|
Use the skill on at least three real changes of different shapes. Measure:
|
||||||
|
|
||||||
|
- whether it activates appropriately;
|
||||||
|
- time from request to approved plan and first implementation edit;
|
||||||
|
- missed or unnecessary gates;
|
||||||
|
- unverified success claims;
|
||||||
|
- review findings caught before integration;
|
||||||
|
- recovery time after a fresh session;
|
||||||
|
- human interventions caused by workflow mechanics rather than product decisions.
|
||||||
|
|
||||||
|
After the pilot, either tune the skill or document one narrow, repeated deterministic gap. Build a helper extension only if evidence shows that prompts and existing primitives cannot close that gap. Any helper may record or validate evidence but must not own stage orchestration or launch agents.
|
||||||
|
|
||||||
|
Once the pilot passes, remove the old extension from managed exposure, archive its implementation and tests in a separate cleanup change, and retain its ADRs as superseded history.
|
||||||
|
|
||||||
|
**Success criteria:** three real changes complete without orchestration failures; the user approves retirement; no replacement extension is added without a demonstrated repeated failure.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- Feature and multi-step work activate the workflow by default; questions and trivial changes do not.
|
||||||
|
- Full-path production edits never begin before explicit approval of the recorded plan; light-path edits remain low-risk and promote to the full path when that assumption changes.
|
||||||
|
- Non-trivial UI never reaches production implementation before target-interface validation and human direction approval.
|
||||||
|
- Risky actions receive scoped just-in-time approval.
|
||||||
|
- Every mutating assignment has one named owner and deadline; observed Git state is checked before and after it; unknown child liveness freezes reassignment; and reviewers never overlap with mutators.
|
||||||
|
- Verification evidence comes from commands run against the integrated worktree state.
|
||||||
|
- Standards and specification reviews remain separate and both must approve.
|
||||||
|
- Review remediation is implemented by a non-reviewer and causes verification plus review to repeat.
|
||||||
|
- A fresh session discovers the active worktree and last committed valid record, preserves orphan worktrees, proves prior mutating children are terminal, then recovers the next safe action without custom persisted runtime state.
|
||||||
|
- Successful work is rebased, reverified, independently reviewed, committed, fast-forward integrated locally, and cleaned up without a routine final approval gate.
|
||||||
|
- Push, publish, deploy, production access, unsafe cleanup, other destructive operations, and unsafe integration remain explicit human gates; deletion of the workflow-owned worktree and branch is pre-authorized only after Phase 8's exact safety checks pass.
|
||||||
|
- Missing human-interaction, target-validation, Git-review, or worktree capabilities stop full-path work before product mutation; optional UI conveniences may degrade without bypassing gates.
|
||||||
|
- The first version adds no extension and no dependency.
|
||||||
|
|
||||||
|
## Future extension threshold
|
||||||
|
|
||||||
|
Consider a minimal extension only after at least two real failures share the same deterministic cause and a skill revision cannot make the completion criterion observable. Acceptable candidates are atomic workflow-record updates or a read-only readiness check. Stage engines, custom schedulers, child runtimes, dashboards, and hidden automatic transitions remain out of scope.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# pi-interactive-subagents
|
# pi-interactive-subagents
|
||||||
|
|
||||||
Async subagents for [pi](https://github.com/badlogic/pi-mono), running in Herdr tabs, Orca terminal tabs, or tmux panes. Spawn a sub-agent, keep working in the main session, and get the result steered back when it finishes. Fully non-blocking.
|
Async subagents for [pi](https://github.com/badlogic/pi-mono), running in Herdr tabs, Orca terminal tabs, or tmux panes. Spawn a sub-agent, keep working in the main session, and get the result as an automatic follow-up when it finishes. Fully non-blocking.
|
||||||
|
|
||||||
The backend is selected automatically: Herdr is used when Pi runs in a Herdr-managed pane (`HERDR_ENV=1`), then Orca when Pi runs in an Orca-managed terminal (`ORCA_TERMINAL_HANDLE`), otherwise tmux is used when available.
|
The backend is selected automatically: Herdr is used when Pi runs in a Herdr-managed pane (`HERDR_ENV=1`), then Orca when Pi runs in an Orca-managed terminal (`ORCA_TERMINAL_HANDLE`), otherwise tmux is used when available.
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ Subagents 2 running
|
|||||||
○ 00:45 scout-2 waiting 2m
|
○ 00:45 scout-2 waiting 2m
|
||||||
```
|
```
|
||||||
|
|
||||||
Spawn several in parallel — they run concurrently and steer results back independently as each finishes.
|
Spawn several in parallel — they run concurrently and deliver results independently as each finishes.
|
||||||
|
|
||||||
Running subagents survive a parent `/reload`. Their terminal sessions and completion watchers stay active, then rebind result delivery and the status widget to the fresh Pi extension runtime. Replacing or closing the parent session still cancels its running subagents. This lifecycle behavior requires Pi 0.68 or newer.
|
Running subagents survive a parent `/reload`. Their terminal sessions and completion watchers stay active, then rebind result delivery and the status widget to the fresh Pi extension runtime. Replacing or closing the parent session still cancels its running subagents. This lifecycle behavior requires Pi 0.68 or newer.
|
||||||
|
|
||||||
@@ -62,8 +62,8 @@ subagent({ agent: "worker", name: "dark-mode", task: "Implement the dark mode to
|
|||||||
subagent_message({ name: "scout", message: "Also check the auth middleware" });
|
subagent_message({ name: "scout", message: "Also check the auth middleware" });
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Running** — the message is typed into the live pane (newlines flattened) and picked up at the next turn boundary. The call returns immediately; the eventual completion still arrives as a steer message.
|
- **Running** — the message is typed into the live pane (newlines flattened) and picked up at the next turn boundary. The call returns immediately; the eventual completion still arrives as an automatic follow-up.
|
||||||
- **Finished** — the session is resumed with the message as the follow-up task, like a fresh spawn: fire-and-forget, always autonomous, result steered back later. The resumed run reclaims its original name.
|
- **Finished** — a Pi-backed session is resumed with the message as the follow-up task, like a fresh spawn: fire-and-forget, always autonomous, result delivered automatically later. The resumed run reclaims its original name. Claude CLI sessions can be steered while running but cannot currently be resumed after completion.
|
||||||
|
|
||||||
Every spawn records name → session file in `artifacts/<sessionId>/subagent-registry.json`, so names stay addressable across pi restarts. A nested sub-agent that spawns children gets its own registry keyed by its own session id. Resume is refused with a clear error (listing known names) if the name isn't registered, the session file is gone, or the session predates sandboxed resume.
|
Every spawn records name → session file in `artifacts/<sessionId>/subagent-registry.json`, so names stay addressable across pi restarts. A nested sub-agent that spawns children gets its own registry keyed by its own session id. Resume is refused with a clear error (listing known names) if the name isn't registered, the session file is gone, or the session predates sandboxed resume.
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ You are a specialized agent that does X...
|
|||||||
| `skills` | string | Comma-separated skill names to auto-load |
|
| `skills` | string | Comma-separated skill names to auto-load |
|
||||||
| `session-mode` | string | `standalone` (default), `lineage-only`, or `fork` — see below |
|
| `session-mode` | string | `standalone` (default), `lineage-only`, or `fork` — see below |
|
||||||
| `system-prompt` | string | `append` or `replace`: pass the body as the child's `--append-system-prompt` / `--system-prompt`. Omit and the body is prepended to the task prompt instead |
|
| `system-prompt` | string | `append` or `replace`: pass the body as the child's `--append-system-prompt` / `--system-prompt`. Omit and the body is prepended to the task prompt instead |
|
||||||
| `auto-exit` | boolean | Auto-shutdown when the agent finishes (see below) |
|
| `auto-exit` | boolean | Auto-shutdown when the agent finishes. Defaults to `true` (see below) |
|
||||||
| `interactive` | boolean | Whether stall/recovery transitions wake the parent (see below) |
|
| `interactive` | boolean | Whether stall/recovery transitions wake the parent (see below) |
|
||||||
| `cwd` | string | Default working directory |
|
| `cwd` | string | Default working directory |
|
||||||
| `disable-model-invocation` | boolean | Hide from `subagents_list`; still spawnable by explicit name |
|
| `disable-model-invocation` | boolean | Hide from `subagents_list`; still spawnable by explicit name |
|
||||||
@@ -130,7 +130,7 @@ You are a specialized agent that does X...
|
|||||||
|
|
||||||
### auto-exit
|
### auto-exit
|
||||||
|
|
||||||
With `auto-exit: true`, the session shuts down when the agent's turn ends — the agent just writes its final message and stops (there is no "done" tool). The last assistant message becomes the summary returned to the parent. Recommended for all autonomous agents.
|
`auto-exit` defaults to `true`, matching the fire-and-forget tool contract: the session shuts down when the agent's turn ends, and the parent receives the last assistant message automatically (there is no "done" tool). Set `auto-exit: false` only for a user-driven session that should remain open until the user exits its pane.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jbcode/pi-interactive-subagents",
|
"name": "@jbcode/pi-interactive-subagents",
|
||||||
"version": "3.7.3",
|
"version": "3.7.4",
|
||||||
"description": "Interactive subagents for Pi — spawn, orchestrate, and manage sub-agent sessions in Orca, Herdr or tmux.",
|
"description": "Interactive subagents for Pi — spawn, orchestrate, and manage sub-agent sessions in Orca, Herdr or tmux.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -166,13 +166,35 @@ export async function readScreenAsync(surface: string, lines = 50): Promise<stri
|
|||||||
return stdout;
|
return stdout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function isSurfaceAlive(surface: string, signal?: AbortSignal): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
requireHerdr();
|
||||||
|
await execFileAsync(
|
||||||
|
"herdr",
|
||||||
|
["pane", "read", surface, "--source", "recent-unwrapped", "--lines", "1"],
|
||||||
|
{ encoding: "utf8", timeout: 5_000, killSignal: "SIGKILL", signal },
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function closeSurface(surface: string): void {
|
export function closeSurface(surface: string): void {
|
||||||
requireHerdr();
|
requireHerdr();
|
||||||
const tabId = tabByPane.get(surface);
|
const tabId = tabByPane.get(surface);
|
||||||
if (tabId) {
|
if (tabId) {
|
||||||
execFileSync("herdr", ["tab", "close", tabId], { encoding: "utf8" });
|
try {
|
||||||
|
execFileSync("herdr", ["tab", "close", tabId], { encoding: "utf8" });
|
||||||
|
} catch {
|
||||||
|
// Replaced-shell launches may close their own tab before watcher cleanup.
|
||||||
|
}
|
||||||
tabByPane.delete(surface);
|
tabByPane.delete(surface);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
execFileSync("herdr", ["pane", "close", surface], { encoding: "utf8" });
|
try {
|
||||||
|
execFileSync("herdr", ["pane", "close", surface], { encoding: "utf8" });
|
||||||
|
} catch {
|
||||||
|
// Closing an already-ended pane is idempotent cleanup.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
mkdirSync,
|
mkdirSync,
|
||||||
copyFileSync,
|
copyFileSync,
|
||||||
unlinkSync,
|
unlinkSync,
|
||||||
|
rmSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
sendLongCommand,
|
sendLongCommand,
|
||||||
pollForExit,
|
pollForExit,
|
||||||
closeSurface,
|
closeSurface,
|
||||||
|
isSurfaceAlive,
|
||||||
shellEscape,
|
shellEscape,
|
||||||
readScreen,
|
readScreen,
|
||||||
getMuxBackend,
|
getMuxBackend,
|
||||||
@@ -350,10 +352,11 @@ function discoverAgentDefinitions(): ListedAgentDefinition[] {
|
|||||||
function resolveSubagentPaths(
|
function resolveSubagentPaths(
|
||||||
params: Static<typeof SubagentParams>,
|
params: Static<typeof SubagentParams>,
|
||||||
agentDefs: AgentDefaults | null,
|
agentDefs: AgentDefaults | null,
|
||||||
|
parentCwd = process.cwd(),
|
||||||
): { effectiveCwd: string | null; localAgentDir: string | null; effectiveAgentDir: string } {
|
): { effectiveCwd: string | null; localAgentDir: string | null; effectiveAgentDir: string } {
|
||||||
const rawCwd = params.cwd ?? agentDefs?.cwd ?? null;
|
const rawCwd = params.cwd ?? agentDefs?.cwd ?? null;
|
||||||
const cwdIsFromAgent = !params.cwd && agentDefs?.cwd != null;
|
const cwdIsFromAgent = !params.cwd && agentDefs?.cwd != null;
|
||||||
const cwdBase = cwdIsFromAgent ? getAgentConfigDir() : process.cwd();
|
const cwdBase = cwdIsFromAgent ? getAgentConfigDir() : parentCwd;
|
||||||
const effectiveCwd = rawCwd
|
const effectiveCwd = rawCwd
|
||||||
? rawCwd.startsWith("/")
|
? rawCwd.startsWith("/")
|
||||||
? rawCwd
|
? rawCwd
|
||||||
@@ -400,22 +403,37 @@ function resolveLaunchBehavior(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire-and-forget launches must terminate without a human closing their pane.
|
||||||
|
* Profiles can opt into a user-driven session with `auto-exit: false`.
|
||||||
|
*/
|
||||||
|
function resolveEffectiveAutoExit(agentDefs: AgentDefaults | null): boolean {
|
||||||
|
return agentDefs?.autoExit ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoExitEnvAssignment(autoExit: boolean): string {
|
||||||
|
return `PI_SUBAGENT_AUTO_EXIT=${autoExit ? "1" : "0"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveClaudeSentinelFile(id: string, autoExit: boolean): string | undefined {
|
||||||
|
return autoExit ? `/tmp/pi-claude-${id}-done` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decide whether a subagent is interactive (user-driven, long-running).
|
* Decide whether a subagent is interactive (user-driven, long-running).
|
||||||
*
|
*
|
||||||
* Resolution order:
|
* Resolution order:
|
||||||
* 1. Explicit `interactive` frontmatter field on the agent.
|
* 1. Explicit `interactive` frontmatter field on the agent.
|
||||||
* 2. Default: the inverse of `auto-exit`. Agents that auto-exit are
|
* 2. Default: the inverse of the effective `auto-exit` behavior. Autonomous
|
||||||
* autonomous (scout, researcher) and the parent session should be
|
* agents wake the parent on stall/recovery transitions; explicitly
|
||||||
* woken on stall/recovery transitions. Agents that don't auto-exit are
|
* user-driven agents stay quiet because the user is already in their pane.
|
||||||
* driven by the user in their own pane (worker) and stall pings are noise.
|
|
||||||
*/
|
*/
|
||||||
function resolveEffectiveInteractive(
|
function resolveEffectiveInteractive(
|
||||||
_params: Static<typeof SubagentParams>,
|
_params: Static<typeof SubagentParams>,
|
||||||
agentDefs: AgentDefaults | null,
|
agentDefs: AgentDefaults | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (agentDefs?.interactive != null) return agentDefs.interactive;
|
if (agentDefs?.interactive != null) return agentDefs.interactive;
|
||||||
return !(agentDefs?.autoExit ?? false);
|
return !resolveEffectiveAutoExit(agentDefs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadAgentDefaults(agentName: string): AgentDefaults | null {
|
function loadAgentDefaults(agentName: string): AgentDefaults | null {
|
||||||
@@ -687,7 +705,10 @@ interface RunningSubagent {
|
|||||||
cli?: string;
|
cli?: string;
|
||||||
sentinelFile?: string;
|
sentinelFile?: string;
|
||||||
completionFile?: string;
|
completionFile?: string;
|
||||||
|
completionObserved?: boolean;
|
||||||
statusState: SubagentStatusState;
|
statusState: SubagentStatusState;
|
||||||
|
/** Parent-session generation. Reload preserves it; replacement increments it. */
|
||||||
|
parentGeneration: number;
|
||||||
/**
|
/**
|
||||||
* When true, status transitions (stalled/recovered) do not wake the parent
|
* When true, status transitions (stalled/recovered) do not wake the parent
|
||||||
* session via a steer message. The widget still updates locally. Used for
|
* session via a steer message. The widget still updates locally. Used for
|
||||||
@@ -731,13 +752,16 @@ function isStatusWidgetClaim(value: unknown): value is StatusWidgetClaim {
|
|||||||
|
|
||||||
interface SharedSubagentRuntime {
|
interface SharedSubagentRuntime {
|
||||||
runningSubagents: Map<string, RunningSubagent>;
|
runningSubagents: Map<string, RunningSubagent>;
|
||||||
|
resumingNames: Set<string>;
|
||||||
latestCtx: ExtensionContext | null;
|
latestCtx: ExtensionContext | null;
|
||||||
latestPi: ExtensionAPI | null;
|
latestPi: ExtensionAPI | null;
|
||||||
widgetInterval: ReturnType<typeof setInterval> | null;
|
widgetInterval: ReturnType<typeof setInterval> | null;
|
||||||
statusInterval: ReturnType<typeof setInterval> | null;
|
statusInterval: ReturnType<typeof setInterval> | null;
|
||||||
standaloneWidgetSyncScheduled: boolean;
|
standaloneWidgetSyncScheduled: boolean;
|
||||||
pendingDeliveries: PendingDelivery[];
|
pendingDeliveries: PendingDelivery[];
|
||||||
|
resultDeliveryStates: Map<string, "queued" | "delivered">;
|
||||||
deliveryFlushScheduled: boolean;
|
deliveryFlushScheduled: boolean;
|
||||||
|
parentGeneration: number;
|
||||||
activeRuntimeToken: symbol | null;
|
activeRuntimeToken: symbol | null;
|
||||||
registeredRuntimeToken: symbol | null;
|
registeredRuntimeToken: symbol | null;
|
||||||
statusWidgetClaim: StatusWidgetClaim | null;
|
statusWidgetClaim: StatusWidgetClaim | null;
|
||||||
@@ -748,13 +772,16 @@ interface SharedSubagentRuntime {
|
|||||||
function createSharedRuntime(): SharedSubagentRuntime {
|
function createSharedRuntime(): SharedSubagentRuntime {
|
||||||
return {
|
return {
|
||||||
runningSubagents: new Map(),
|
runningSubagents: new Map(),
|
||||||
|
resumingNames: new Set(),
|
||||||
latestCtx: null,
|
latestCtx: null,
|
||||||
latestPi: null,
|
latestPi: null,
|
||||||
widgetInterval: null,
|
widgetInterval: null,
|
||||||
statusInterval: null,
|
statusInterval: null,
|
||||||
standaloneWidgetSyncScheduled: false,
|
standaloneWidgetSyncScheduled: false,
|
||||||
pendingDeliveries: [],
|
pendingDeliveries: [],
|
||||||
|
resultDeliveryStates: new Map(),
|
||||||
deliveryFlushScheduled: false,
|
deliveryFlushScheduled: false,
|
||||||
|
parentGeneration: 0,
|
||||||
activeRuntimeToken: null,
|
activeRuntimeToken: null,
|
||||||
registeredRuntimeToken: null,
|
registeredRuntimeToken: null,
|
||||||
statusWidgetClaim: null,
|
statusWidgetClaim: null,
|
||||||
@@ -783,9 +810,36 @@ if (sharedRuntime.activeRuntimeToken !== null && typeof sharedRuntime.activeRunt
|
|||||||
if (typeof sharedRuntime.standaloneWidgetSyncScheduled !== "boolean") {
|
if (typeof sharedRuntime.standaloneWidgetSyncScheduled !== "boolean") {
|
||||||
sharedRuntime.standaloneWidgetSyncScheduled = false;
|
sharedRuntime.standaloneWidgetSyncScheduled = false;
|
||||||
}
|
}
|
||||||
|
if (!(sharedRuntime.resultDeliveryStates instanceof Map)) {
|
||||||
|
sharedRuntime.resultDeliveryStates = new Map();
|
||||||
|
}
|
||||||
|
if (!(sharedRuntime.resumingNames instanceof Set)) {
|
||||||
|
sharedRuntime.resumingNames = new Set();
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(sharedRuntime.parentGeneration)) {
|
||||||
|
sharedRuntime.parentGeneration = 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** All currently running subagents, preserved across /reload. */
|
/** All currently running subagents, preserved across /reload. */
|
||||||
const runningSubagents = sharedRuntime.runningSubagents;
|
const runningSubagents = sharedRuntime.runningSubagents;
|
||||||
|
const resumingNames = sharedRuntime.resumingNames;
|
||||||
|
|
||||||
|
function reserveResumeName(name: string): boolean {
|
||||||
|
if (resumingNames.has(name)) return false;
|
||||||
|
resumingNames.add(name);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStaleSessionSignals(sessionFile: string): void {
|
||||||
|
rmSync(`${sessionFile}.exit`, { force: true });
|
||||||
|
rmSync(`${sessionFile}.ask`, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const running of runningSubagents.values()) {
|
||||||
|
if (!Number.isSafeInteger(running.parentGeneration)) {
|
||||||
|
running.parentGeneration = sharedRuntime.parentGeneration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// When this extension is loaded inside a subagent that itself spawns children
|
// When this extension is loaded inside a subagent that itself spawns children
|
||||||
// (e.g. a worker delegating to scout/researcher), `subagent-done.ts` runs in the
|
// (e.g. a worker delegating to scout/researcher), `subagent-done.ts` runs in the
|
||||||
@@ -798,22 +852,83 @@ const RUNNING_CHILDREN_COUNT_KEY = Symbol.for("pi-subagents/running-children-cou
|
|||||||
|
|
||||||
// ── Widget and runtime delivery management ──
|
// ── Widget and runtime delivery management ──
|
||||||
|
|
||||||
|
const MAX_RESULT_DELIVERY_TOMBSTONES = 1_024;
|
||||||
|
const AUTOMATIC_DELIVERY_OPTIONS = { triggerTurn: true, deliverAs: "followUp" as const };
|
||||||
|
|
||||||
|
function getReadyRuntimePi(): ExtensionAPI | null {
|
||||||
|
if (
|
||||||
|
sharedRuntime.latestPi
|
||||||
|
&& sharedRuntime.activeRuntimeToken !== null
|
||||||
|
&& sharedRuntime.activeRuntimeToken === sharedRuntime.registeredRuntimeToken
|
||||||
|
) {
|
||||||
|
return sharedRuntime.latestPi;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function sendWhenRuntimeReady(delivery: PendingDelivery): void {
|
function sendWhenRuntimeReady(delivery: PendingDelivery): void {
|
||||||
const pi = sharedRuntime.latestPi;
|
const pi = getReadyRuntimePi();
|
||||||
if (pi) {
|
if (pi) {
|
||||||
delivery(pi);
|
try {
|
||||||
return;
|
delivery(pi);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Retry against the next bound runtime. A failed notification must not
|
||||||
|
// prevent sibling callbacks from being processed.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sharedRuntime.pendingDeliveries.push(delivery);
|
sharedRuntime.pendingDeliveries.push(delivery);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pruneResultDeliveryTombstones(): void {
|
||||||
|
if (sharedRuntime.resultDeliveryStates.size <= MAX_RESULT_DELIVERY_TOMBSTONES) return;
|
||||||
|
for (const [id, state] of sharedRuntime.resultDeliveryStates) {
|
||||||
|
if (state !== "delivered") continue;
|
||||||
|
sharedRuntime.resultDeliveryStates.delete(id);
|
||||||
|
if (sharedRuntime.resultDeliveryStates.size <= MAX_RESULT_DELIVERY_TOMBSTONES) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queue or deliver one terminal result for a child id, rejecting duplicates. */
|
||||||
|
function sendResultWhenRuntimeReady(id: string, delivery: PendingDelivery): boolean {
|
||||||
|
if (sharedRuntime.resultDeliveryStates.has(id)) return false;
|
||||||
|
sharedRuntime.resultDeliveryStates.set(id, "queued");
|
||||||
|
|
||||||
|
const deliverOnce = (pi: ExtensionAPI) => {
|
||||||
|
if (sharedRuntime.resultDeliveryStates.get(id) === "delivered") return;
|
||||||
|
delivery(pi);
|
||||||
|
// Mark only after sendMessage succeeds so a stale runtime can be retried.
|
||||||
|
sharedRuntime.resultDeliveryStates.delete(id);
|
||||||
|
sharedRuntime.resultDeliveryStates.set(id, "delivered");
|
||||||
|
pruneResultDeliveryTombstones();
|
||||||
|
};
|
||||||
|
|
||||||
|
const pi = getReadyRuntimePi();
|
||||||
|
if (pi) {
|
||||||
|
try {
|
||||||
|
deliverOnce(pi);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// Keep the queued state and retry after a fresh runtime binds.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sharedRuntime.pendingDeliveries.push(deliverOnce);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function flushPendingDeliveries(): void {
|
function flushPendingDeliveries(): void {
|
||||||
sharedRuntime.deliveryFlushScheduled = false;
|
sharedRuntime.deliveryFlushScheduled = false;
|
||||||
const pi = sharedRuntime.latestPi;
|
const pi = getReadyRuntimePi();
|
||||||
if (!pi || sharedRuntime.pendingDeliveries.length === 0) return;
|
if (!pi || sharedRuntime.pendingDeliveries.length === 0) return;
|
||||||
|
|
||||||
const pending = sharedRuntime.pendingDeliveries.splice(0);
|
const pending = sharedRuntime.pendingDeliveries.splice(0);
|
||||||
for (const delivery of pending) delivery(pi);
|
for (const delivery of pending) {
|
||||||
|
try {
|
||||||
|
delivery(pi);
|
||||||
|
} catch {
|
||||||
|
sharedRuntime.pendingDeliveries.push(delivery);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function schedulePendingDeliveryFlush(): void {
|
function schedulePendingDeliveryFlush(): void {
|
||||||
@@ -1366,6 +1481,28 @@ function steerSubagent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasTerminalCompletionSignal(running: RunningSubagent): boolean {
|
||||||
|
return !!(
|
||||||
|
running.completionObserved
|
||||||
|
|| (running.completionFile && existsSync(running.completionFile))
|
||||||
|
|| existsSync(`${running.sessionFile}.exit`)
|
||||||
|
|| (running.sentinelFile && existsSync(running.sentinelFile))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForWatcherCleanup(
|
||||||
|
running: RunningSubagent,
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
|
timeoutMs = 2_000,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (runningSubagents.has(running.id) && Date.now() < deadline) {
|
||||||
|
if (signal?.aborted) return false;
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
||||||
|
}
|
||||||
|
return !runningSubagents.has(running.id);
|
||||||
|
}
|
||||||
|
|
||||||
function handleSubagentSteer(
|
function handleSubagentSteer(
|
||||||
params: { name?: string; message?: string },
|
params: { name?: string; message?: string },
|
||||||
send: (surface: string, command: string) => void = sendCommand,
|
send: (surface: string, command: string) => void = sendCommand,
|
||||||
@@ -1466,12 +1603,26 @@ function startStatusRefresh() {
|
|||||||
|
|
||||||
// Resuming a finished session is always autonomous: the relaunched agent runs
|
// Resuming a finished session is always autonomous: the relaunched agent runs
|
||||||
// its follow-up task to completion and the harness delivers the result as a
|
// its follow-up task to completion and the harness delivers the result as a
|
||||||
// steer message (fire-and-forget). An interactive resume would park the pane
|
// queued follow-up notification (fire-and-forget). An interactive resume would park the pane
|
||||||
// waiting for the user, contradicting that result-delivery model.
|
// waiting for the user, contradicting that result-delivery model.
|
||||||
function resolveResumeLaunchBehavior(): { autoExit: boolean; interactive: boolean } {
|
function resolveResumeLaunchBehavior(): { autoExit: boolean; interactive: boolean } {
|
||||||
return { autoExit: true, interactive: false };
|
return { autoExit: true, interactive: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldDeliverRunningResult(running: RunningSubagent): boolean {
|
||||||
|
return !running.suppressDelivery && running.parentGeneration === sharedRuntime.parentGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertLaunchActive(signal: AbortSignal | undefined, runtimeToken: symbol): void {
|
||||||
|
if (
|
||||||
|
signal?.aborted
|
||||||
|
|| sharedRuntime.activeRuntimeToken !== runtimeToken
|
||||||
|
|| sharedRuntime.registeredRuntimeToken !== runtimeToken
|
||||||
|
) {
|
||||||
|
throw new Error("Parent session ended or was replaced before the subagent launch completed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const __test__ = {
|
export const __test__ = {
|
||||||
borderLine,
|
borderLine,
|
||||||
defaultShellReadyDelayMs,
|
defaultShellReadyDelayMs,
|
||||||
@@ -1481,8 +1632,12 @@ export const __test__ = {
|
|||||||
updateWidget,
|
updateWidget,
|
||||||
loadAgentDefaults,
|
loadAgentDefaults,
|
||||||
discoverAgentDefinitions,
|
discoverAgentDefinitions,
|
||||||
|
resolveSubagentPaths,
|
||||||
resolveEffectiveSessionMode,
|
resolveEffectiveSessionMode,
|
||||||
resolveLaunchBehavior,
|
resolveLaunchBehavior,
|
||||||
|
resolveEffectiveAutoExit,
|
||||||
|
autoExitEnvAssignment,
|
||||||
|
resolveClaudeSentinelFile,
|
||||||
resolveEffectiveInteractive,
|
resolveEffectiveInteractive,
|
||||||
buildSubagentToolAllowlist,
|
buildSubagentToolAllowlist,
|
||||||
applySandboxToParts,
|
applySandboxToParts,
|
||||||
@@ -1493,10 +1648,18 @@ export const __test__ = {
|
|||||||
resolveRunningByName,
|
resolveRunningByName,
|
||||||
uniqueRunningName,
|
uniqueRunningName,
|
||||||
reservedNames,
|
reservedNames,
|
||||||
|
resumingNames,
|
||||||
|
reserveResumeName,
|
||||||
|
clearStaleSessionSignals,
|
||||||
|
hasTerminalCompletionSignal,
|
||||||
|
waitForWatcherCleanup,
|
||||||
|
deliverPendingQuestion,
|
||||||
steerSubagent,
|
steerSubagent,
|
||||||
handleSubagentSteer,
|
handleSubagentSteer,
|
||||||
resolveResultPresentation,
|
resolveResultPresentation,
|
||||||
resolveResumeLaunchBehavior,
|
resolveResumeLaunchBehavior,
|
||||||
|
shouldDeliverRunningResult,
|
||||||
|
assertLaunchActive,
|
||||||
wrapWithCompletionFile,
|
wrapWithCompletionFile,
|
||||||
runningSubagents,
|
runningSubagents,
|
||||||
formatElapsed,
|
formatElapsed,
|
||||||
@@ -1506,6 +1669,8 @@ export const __test__ = {
|
|||||||
formatUsageSegments,
|
formatUsageSegments,
|
||||||
widgetIcon,
|
widgetIcon,
|
||||||
sendWhenRuntimeReady,
|
sendWhenRuntimeReady,
|
||||||
|
sendResultWhenRuntimeReady,
|
||||||
|
AUTOMATIC_DELIVERY_OPTIONS,
|
||||||
flushPendingDeliveries,
|
flushPendingDeliveries,
|
||||||
sharedRuntime,
|
sharedRuntime,
|
||||||
};
|
};
|
||||||
@@ -1534,8 +1699,9 @@ function startWidgetRefresh() {
|
|||||||
async function launchSubagent(
|
async function launchSubagent(
|
||||||
params: Static<typeof SubagentParams>,
|
params: Static<typeof SubagentParams>,
|
||||||
ctx: { sessionManager: { getSessionFile(): string | null | undefined; getSessionId(): string; getSessionDir(): string }; cwd: string },
|
ctx: { sessionManager: { getSessionFile(): string | null | undefined; getSessionId(): string; getSessionDir(): string }; cwd: string },
|
||||||
options?: { surface?: string },
|
options?: { surface?: string; ensureActive?: () => void },
|
||||||
): Promise<RunningSubagent> {
|
): Promise<RunningSubagent> {
|
||||||
|
options?.ensureActive?.();
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const id = Math.random().toString(16).slice(2, 10);
|
const id = Math.random().toString(16).slice(2, 10);
|
||||||
const name = params.name?.trim() || params.agent;
|
const name = params.name?.trim() || params.agent;
|
||||||
@@ -1545,6 +1711,7 @@ async function launchSubagent(
|
|||||||
const effectiveTools = agentDefs?.tools;
|
const effectiveTools = agentDefs?.tools;
|
||||||
const effectiveSkills = agentDefs?.skills;
|
const effectiveSkills = agentDefs?.skills;
|
||||||
const effectiveThinking = agentDefs?.thinking;
|
const effectiveThinking = agentDefs?.thinking;
|
||||||
|
const effectiveAutoExit = resolveEffectiveAutoExit(agentDefs);
|
||||||
const effectiveInteractive = resolveEffectiveInteractive(params, agentDefs);
|
const effectiveInteractive = resolveEffectiveInteractive(params, agentDefs);
|
||||||
|
|
||||||
const sessionFile = ctx.sessionManager.getSessionFile();
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
||||||
@@ -1554,7 +1721,11 @@ async function launchSubagent(
|
|||||||
const completionFile = join(artifactDir, "subagent-exits", `${id}.exit-code`);
|
const completionFile = join(artifactDir, "subagent-exits", `${id}.exit-code`);
|
||||||
mkdirSync(dirname(completionFile), { recursive: true });
|
mkdirSync(dirname(completionFile), { recursive: true });
|
||||||
|
|
||||||
const { effectiveCwd, localAgentDir, effectiveAgentDir } = resolveSubagentPaths(params, agentDefs);
|
const { effectiveCwd, localAgentDir, effectiveAgentDir } = resolveSubagentPaths(
|
||||||
|
params,
|
||||||
|
agentDefs,
|
||||||
|
ctx.cwd,
|
||||||
|
);
|
||||||
const targetCwdForSession = effectiveCwd ?? ctx.cwd;
|
const targetCwdForSession = effectiveCwd ?? ctx.cwd;
|
||||||
const sessionDir = getDefaultSessionDirFor(targetCwdForSession, effectiveAgentDir);
|
const sessionDir = getDefaultSessionDirFor(targetCwdForSession, effectiveAgentDir);
|
||||||
|
|
||||||
@@ -1579,6 +1750,7 @@ async function launchSubagent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
options?.ensureActive?.();
|
||||||
const launchBehavior = resolveLaunchBehavior(params, agentDefs);
|
const launchBehavior = resolveLaunchBehavior(params, agentDefs);
|
||||||
|
|
||||||
if (launchBehavior.seededSessionMode) {
|
if (launchBehavior.seededSessionMode) {
|
||||||
@@ -1597,10 +1769,10 @@ async function launchSubagent(
|
|||||||
// Build the task message
|
// Build the task message
|
||||||
// Only full-context fork mode inherits prior conversation state.
|
// Only full-context fork mode inherits prior conversation state.
|
||||||
// Blank-session modes need the wrapper instructions and artifact-backed handoff.
|
// Blank-session modes need the wrapper instructions and artifact-backed handoff.
|
||||||
const modeHint = agentDefs?.autoExit
|
const modeHint = effectiveAutoExit
|
||||||
? "Complete your task autonomously. When you are finished, simply stop — your session ends automatically."
|
? "Complete your task autonomously. When you are finished, simply stop — your session ends automatically."
|
||||||
: "Complete your task. The user can interact with you at any time, and the session ends when the user exits the pane.";
|
: "Complete your task. The user can interact with you at any time, and the session ends when the user exits the pane.";
|
||||||
const summaryInstruction = agentDefs?.autoExit
|
const summaryInstruction = effectiveAutoExit
|
||||||
? "Your FINAL assistant message should summarize what you accomplished."
|
? "Your FINAL assistant message should summarize what you accomplished."
|
||||||
: "Your FINAL assistant message (before the user exits) should summarize what you accomplished.";
|
: "Your FINAL assistant message (before the user exits) should summarize what you accomplished.";
|
||||||
// An agent with a non-empty subagent_agents list is granted the spawning
|
// An agent with a non-empty subagent_agents list is granted the spawning
|
||||||
@@ -1615,11 +1787,13 @@ async function launchSubagent(
|
|||||||
: `${roleBlock}\n\n${modeHint}\n\n${params.task}\n\n${summaryInstruction}`;
|
: `${roleBlock}\n\n${modeHint}\n\n${params.task}\n\n${summaryInstruction}`;
|
||||||
// ── Claude Code CLI path ──
|
// ── Claude Code CLI path ──
|
||||||
if (agentDefs?.cli === "claude") {
|
if (agentDefs?.cli === "claude") {
|
||||||
const sentinelFile = `/tmp/pi-claude-${id}-done`;
|
const sentinelFile = resolveClaudeSentinelFile(id, effectiveAutoExit);
|
||||||
const pluginDir = join(SUBAGENTS_DIR, "plugin");
|
const pluginDir = join(SUBAGENTS_DIR, "plugin");
|
||||||
|
|
||||||
const cmdParts: string[] = [];
|
const cmdParts: string[] = [];
|
||||||
cmdParts.push(`PI_CLAUDE_SENTINEL=${shellEscape(sentinelFile)}`);
|
if (sentinelFile) {
|
||||||
|
cmdParts.push(`PI_CLAUDE_SENTINEL=${shellEscape(sentinelFile)}`);
|
||||||
|
}
|
||||||
cmdParts.push("claude");
|
cmdParts.push("claude");
|
||||||
cmdParts.push("--dangerously-skip-permissions");
|
cmdParts.push("--dangerously-skip-permissions");
|
||||||
|
|
||||||
@@ -1651,14 +1825,17 @@ async function launchSubagent(
|
|||||||
.replace(/^-|-$/g, "") || "subagent"}-${id}.sh`;
|
.replace(/^-|-$/g, "") || "subagent"}-${id}.sh`;
|
||||||
const launchScriptFile = join(artifactDir, "subagent-scripts", launchScriptName);
|
const launchScriptFile = join(artifactDir, "subagent-scripts", launchScriptName);
|
||||||
|
|
||||||
|
options?.ensureActive?.();
|
||||||
sendLongCommand(surface, command, {
|
sendLongCommand(surface, command, {
|
||||||
scriptPath: launchScriptFile,
|
scriptPath: launchScriptFile,
|
||||||
|
replaceShell: true,
|
||||||
scriptPreamble: [
|
scriptPreamble: [
|
||||||
`# Claude Code subagent launch script for ${name}`,
|
`# Claude Code subagent launch script for ${name}`,
|
||||||
`# Generated: ${new Date().toISOString()}`,
|
`# Generated: ${new Date().toISOString()}`,
|
||||||
`# Surface: ${surface}`,
|
`# Surface: ${surface}`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
});
|
});
|
||||||
|
options?.ensureActive?.();
|
||||||
|
|
||||||
const running: RunningSubagent = {
|
const running: RunningSubagent = {
|
||||||
id,
|
id,
|
||||||
@@ -1677,6 +1854,7 @@ async function launchSubagent(
|
|||||||
source: "claude",
|
source: "claude",
|
||||||
startTimeMs: startTime,
|
startTimeMs: startTime,
|
||||||
}),
|
}),
|
||||||
|
parentGeneration: sharedRuntime.parentGeneration,
|
||||||
};
|
};
|
||||||
|
|
||||||
runningSubagents.set(id, running);
|
runningSubagents.set(id, running);
|
||||||
@@ -1717,7 +1895,7 @@ async function launchSubagent(
|
|||||||
systemPromptMode: systemPromptMode ?? null,
|
systemPromptMode: systemPromptMode ?? null,
|
||||||
identity: identityInSystemPrompt ? identity : null,
|
identity: identityInSystemPrompt ? identity : null,
|
||||||
spawnable: agentDefs?.subagentAgents ?? null,
|
spawnable: agentDefs?.subagentAgents ?? null,
|
||||||
autoExit: agentDefs?.autoExit ?? false,
|
autoExit: effectiveAutoExit,
|
||||||
cwd: effectiveCwd ?? null,
|
cwd: effectiveCwd ?? null,
|
||||||
agentDir: resolvedAgentDir,
|
agentDir: resolvedAgentDir,
|
||||||
};
|
};
|
||||||
@@ -1741,9 +1919,7 @@ async function launchSubagent(
|
|||||||
if (params.agent) {
|
if (params.agent) {
|
||||||
envParts.push(`PI_SUBAGENT_AGENT=${shellEscape(params.agent)}`);
|
envParts.push(`PI_SUBAGENT_AGENT=${shellEscape(params.agent)}`);
|
||||||
}
|
}
|
||||||
if (agentDefs?.autoExit) {
|
envParts.push(autoExitEnvAssignment(effectiveAutoExit));
|
||||||
envParts.push(`PI_SUBAGENT_AUTO_EXIT=1`);
|
|
||||||
}
|
|
||||||
envParts.push(`PI_SUBAGENT_SESSION=${shellEscape(subagentSessionFile)}`);
|
envParts.push(`PI_SUBAGENT_SESSION=${shellEscape(subagentSessionFile)}`);
|
||||||
envParts.push(`PI_SUBAGENT_ID=${shellEscape(id)}`);
|
envParts.push(`PI_SUBAGENT_ID=${shellEscape(id)}`);
|
||||||
envParts.push(`PI_SUBAGENT_ACTIVITY_FILE=${shellEscape(activityFile)}`);
|
envParts.push(`PI_SUBAGENT_ACTIVITY_FILE=${shellEscape(activityFile)}`);
|
||||||
@@ -1793,8 +1969,10 @@ async function launchSubagent(
|
|||||||
.replace(/-+/g, "-")
|
.replace(/-+/g, "-")
|
||||||
.replace(/^-|-$/g, "") || "subagent"}-${id}.sh`;
|
.replace(/^-|-$/g, "") || "subagent"}-${id}.sh`;
|
||||||
const launchScriptFile = join(artifactDir, "subagent-scripts", launchScriptName);
|
const launchScriptFile = join(artifactDir, "subagent-scripts", launchScriptName);
|
||||||
|
options?.ensureActive?.();
|
||||||
sendLongCommand(surface, command, {
|
sendLongCommand(surface, command, {
|
||||||
scriptPath: launchScriptFile,
|
scriptPath: launchScriptFile,
|
||||||
|
replaceShell: true,
|
||||||
scriptPreamble: [
|
scriptPreamble: [
|
||||||
`# Subagent launch script for ${name}`,
|
`# Subagent launch script for ${name}`,
|
||||||
`# Generated: ${new Date().toISOString()}`,
|
`# Generated: ${new Date().toISOString()}`,
|
||||||
@@ -1802,6 +1980,7 @@ async function launchSubagent(
|
|||||||
`# Surface: ${surface}`,
|
`# Surface: ${surface}`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
});
|
});
|
||||||
|
options?.ensureActive?.();
|
||||||
|
|
||||||
const running: RunningSubagent = {
|
const running: RunningSubagent = {
|
||||||
id,
|
id,
|
||||||
@@ -1819,6 +1998,7 @@ async function launchSubagent(
|
|||||||
source: "pi",
|
source: "pi",
|
||||||
startTimeMs: startTime,
|
startTimeMs: startTime,
|
||||||
}),
|
}),
|
||||||
|
parentGeneration: sharedRuntime.parentGeneration,
|
||||||
};
|
};
|
||||||
|
|
||||||
runningSubagents.set(id, running);
|
runningSubagents.set(id, running);
|
||||||
@@ -1870,13 +2050,15 @@ function copyClaudeSession(sentinelFile: string): string | null {
|
|||||||
* delivery so it fires once per question (a subagent may ask again later).
|
* delivery so it fires once per question (a subagent may ask again later).
|
||||||
*/
|
*/
|
||||||
function deliverPendingQuestion(running: RunningSubagent): void {
|
function deliverPendingQuestion(running: RunningSubagent): void {
|
||||||
|
if (!shouldDeliverRunningResult(running)) return;
|
||||||
const askFile = `${running.sessionFile}.ask`;
|
const askFile = `${running.sessionFile}.ask`;
|
||||||
let payload: any = null;
|
let payload: any = null;
|
||||||
try {
|
try {
|
||||||
if (!existsSync(askFile)) return;
|
if (!existsSync(askFile)) return;
|
||||||
payload = JSON.parse(readFileSync(askFile, "utf-8"));
|
payload = JSON.parse(readFileSync(askFile, "utf-8"));
|
||||||
} catch {
|
} catch {
|
||||||
// Malformed/partway-written file — drop it and move on.
|
// A writer may still be replacing the signal. Leave it for the next tick.
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
unlinkSync(askFile);
|
unlinkSync(askFile);
|
||||||
@@ -1889,6 +2071,7 @@ function deliverPendingQuestion(running: RunningSubagent): void {
|
|||||||
const replyHint = `\n\nReply with subagent_message({ name: "${name}", message: "…" }) — the same name works whether it is still running or has since exited. It stays open until you reply.`;
|
const replyHint = `\n\nReply with subagent_message({ name: "${name}", message: "…" }) — the same name works whether it is still running or has since exited. It stays open until you reply.`;
|
||||||
|
|
||||||
sendWhenRuntimeReady((pi) => {
|
sendWhenRuntimeReady((pi) => {
|
||||||
|
if (!shouldDeliverRunningResult(running)) return;
|
||||||
pi.sendMessage(
|
pi.sendMessage(
|
||||||
{
|
{
|
||||||
customType: "subagent_question",
|
customType: "subagent_question",
|
||||||
@@ -1901,7 +2084,7 @@ function deliverPendingQuestion(running: RunningSubagent): void {
|
|||||||
...(sessionId ? { sessionId } : {}),
|
...(sessionId ? { sessionId } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ triggerTurn: true, deliverAs: "steer" },
|
AUTOMATIC_DELIVERY_OPTIONS,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1922,6 +2105,9 @@ async function watchSubagent(
|
|||||||
observeRunningSubagent(running);
|
observeRunningSubagent(running);
|
||||||
deliverPendingQuestion(running);
|
deliverPendingQuestion(running);
|
||||||
},
|
},
|
||||||
|
onExitDetected() {
|
||||||
|
running.completionObserved = true;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||||
@@ -2026,6 +2212,19 @@ async function watchSubagent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function invalidateParentLifecycle(): void {
|
||||||
|
sharedRuntime.pendingDeliveries.length = 0;
|
||||||
|
sharedRuntime.resultDeliveryStates.clear();
|
||||||
|
sharedRuntime.parentGeneration += 1;
|
||||||
|
resumingNames.clear();
|
||||||
|
for (const agent of runningSubagents.values()) {
|
||||||
|
agent.suppressDelivery = true;
|
||||||
|
agent.abortController?.abort();
|
||||||
|
}
|
||||||
|
runningSubagents.clear();
|
||||||
|
clearRefreshIntervals();
|
||||||
|
}
|
||||||
|
|
||||||
export default function subagentsExtension(pi: ExtensionAPI) {
|
export default function subagentsExtension(pi: ExtensionAPI) {
|
||||||
const runtimeToken = Symbol("pi-subagents/runtime");
|
const runtimeToken = Symbol("pi-subagents/runtime");
|
||||||
// The newest registered extension instance is the only one allowed to bind
|
// The newest registered extension instance is the only one allowed to bind
|
||||||
@@ -2036,9 +2235,18 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
// Bind only after Pi has installed the fresh runtime. During /reload the old
|
// Bind only after Pi has installed the fresh runtime. During /reload the old
|
||||||
// session_shutdown handler clears these references first, so watcher results
|
// session_shutdown handler clears these references first, so watcher results
|
||||||
// cannot enqueue a turn through stale extension APIs.
|
// cannot enqueue a turn through stale extension APIs.
|
||||||
pi.on("session_start", (_event, ctx) => {
|
pi.on("session_start", (event, ctx) => {
|
||||||
if (sharedRuntime.registeredRuntimeToken !== runtimeToken) return;
|
if (sharedRuntime.registeredRuntimeToken !== runtimeToken) return;
|
||||||
|
|
||||||
|
// Session replacement can start before the old runtime's shutdown event.
|
||||||
|
// Invalidate the old parent's children and queued callbacks at the start
|
||||||
|
// boundary too; reload is the only transition that preserves them.
|
||||||
|
if (event.reason === "new" || event.reason === "resume" || event.reason === "fork") {
|
||||||
|
clearStandaloneWidget();
|
||||||
|
sharedRuntime.statusWidgetClaim = null;
|
||||||
|
invalidateParentLifecycle();
|
||||||
|
}
|
||||||
|
|
||||||
// A fresh runtime may start before the old runtime emits shutdown. Drop
|
// A fresh runtime may start before the old runtime emits shutdown. Drop
|
||||||
// old UI ownership and intervals before binding the fresh context.
|
// old UI ownership and intervals before binding the fresh context.
|
||||||
if (sharedRuntime.activeRuntimeToken !== runtimeToken) {
|
if (sharedRuntime.activeRuntimeToken !== runtimeToken) {
|
||||||
@@ -2085,12 +2293,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
// Real session replacement/quit owns a different lifecycle. Cancel those
|
// Real session replacement/quit owns a different lifecycle. Cancel those
|
||||||
// children and suppress the synthetic cancellation result; there is no
|
// children and suppress the synthetic cancellation result; there is no
|
||||||
// valid destination session to receive it.
|
// valid destination session to receive it.
|
||||||
sharedRuntime.pendingDeliveries.length = 0;
|
invalidateParentLifecycle();
|
||||||
for (const agent of runningSubagents.values()) {
|
|
||||||
agent.suppressDelivery = true;
|
|
||||||
agent.abortController?.abort();
|
|
||||||
}
|
|
||||||
runningSubagents.clear();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// The spawning tools are always registered here. Whether a child process can
|
// The spawning tools are always registered here. Whether a child process can
|
||||||
@@ -2105,14 +2308,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
description:
|
description:
|
||||||
"Spawn a sub-agent in a dedicated terminal multiplexer pane. " +
|
"Spawn a sub-agent in a dedicated terminal multiplexer pane. " +
|
||||||
"This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " +
|
"This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " +
|
||||||
"When the sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " +
|
"When the sub-agent finishes, the harness AUTOMATICALLY delivers its result as a follow-up notification that wakes you up and starts a new turn — you do not need to do anything to receive it. " +
|
||||||
"DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT call subagents_list or any other tool to 'check' status. All of that is wasted work — the harness handles delivery for you. " +
|
"DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT call subagents_list or any other tool to 'check' status. All of that is wasted work — the harness handles delivery for you. " +
|
||||||
"DO NOT fabricate, assume, or summarize results after calling this tool. " +
|
"DO NOT fabricate, assume, or summarize results after calling this tool. " +
|
||||||
"After spawning, either end your turn immediately, or work on other independent tasks (including spawning more subagents in parallel). The harness will wake you with the result when it is ready.",
|
"After spawning, either end your turn immediately, or work on other independent tasks (including spawning more subagents in parallel). The harness will wake you with the result when it is ready.",
|
||||||
promptSnippet:
|
promptSnippet:
|
||||||
"Spawn a sub-agent in a dedicated terminal multiplexer pane. " +
|
"Spawn a sub-agent in a dedicated terminal multiplexer pane. " +
|
||||||
"This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " +
|
"This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " +
|
||||||
"When the sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " +
|
"When the sub-agent finishes, the harness AUTOMATICALLY delivers its result as a follow-up notification that wakes you up and starts a new turn — you do not need to do anything to receive it. " +
|
||||||
"DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT call subagents_list or any other tool to 'check' status. All of that is wasted work — the harness handles delivery for you. " +
|
"DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT call subagents_list or any other tool to 'check' status. All of that is wasted work — the harness handles delivery for you. " +
|
||||||
"DO NOT fabricate, assume, or summarize results after calling this tool. " +
|
"DO NOT fabricate, assume, or summarize results after calling this tool. " +
|
||||||
"After spawning, either end your turn immediately, or work on other independent tasks (including spawning more subagents in parallel). The harness will wake you with the result when it is ready.",
|
"After spawning, either end your turn immediately, or work on other independent tasks (including spawning more subagents in parallel). The harness will wake you with the result when it is ready.",
|
||||||
@@ -2201,27 +2404,33 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
ctx.sessionManager.getSessionId(),
|
ctx.sessionManager.getSessionId(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Default the cosmetic pane label to the agent name when omitted,
|
const ensureActive = () => assertLaunchActive(_signal, runtimeToken);
|
||||||
// disambiguating against running subagents, in-flight reservations, and
|
ensureActive();
|
||||||
// every name already in the registry — so names stay unique across the
|
|
||||||
// whole session, running or finished. Reserve the chosen name
|
// Disambiguate both defaulted and explicit cosmetic names against
|
||||||
// synchronously (before any await) so parallel spawns don't collide.
|
// running children, parallel launch reservations, and the persistent
|
||||||
let reservedName: string | null = null;
|
// registry. Reserve synchronously before the first await.
|
||||||
if (!params.name?.trim()) {
|
const registryNames = new Set(Object.keys(readNameRegistry(parentArtifactDir)));
|
||||||
const registryNames = new Set(Object.keys(readNameRegistry(parentArtifactDir)));
|
params.name = uniqueRunningName(params.name?.trim() || params.agent, registryNames);
|
||||||
params.name = uniqueRunningName(params.agent, registryNames);
|
const reservedName = params.name;
|
||||||
reservedName = params.name;
|
reservedNames.add(reservedName);
|
||||||
reservedNames.add(reservedName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Launch the subagent (creates pane, sends command). Release the name
|
// Launch the subagent (creates pane, sends command). Release the name
|
||||||
// reservation once it registers in runningSubagents (or launch fails) —
|
// reservation once it registers in runningSubagents (or launch fails) —
|
||||||
// from then on uniqueRunningName tracks it via the running map.
|
// from then on uniqueRunningName tracks it via the running map.
|
||||||
let running;
|
let running: RunningSubagent | undefined;
|
||||||
try {
|
try {
|
||||||
running = await launchSubagent(params, ctx);
|
running = await launchSubagent(params, ctx, { ensureActive });
|
||||||
|
ensureActive();
|
||||||
|
} catch (error) {
|
||||||
|
if (running) {
|
||||||
|
running.suppressDelivery = true;
|
||||||
|
runningSubagents.delete(running.id);
|
||||||
|
try { closeSurface(running.surface); } catch {}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (reservedName) reservedNames.delete(reservedName);
|
reservedNames.delete(reservedName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist name → session so subagent_message({ name }) can resume this
|
// Persist name → session so subagent_message({ name }) can resume this
|
||||||
@@ -2247,10 +2456,10 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
watchSubagent(running, watcherAbort.signal)
|
watchSubagent(running, watcherAbort.signal)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
updateWidget(); // reflect removal from Map immediately
|
updateWidget(); // reflect removal from Map immediately
|
||||||
if (running.suppressDelivery) return;
|
if (!shouldDeliverRunningResult(running)) return;
|
||||||
|
|
||||||
const presentation = resolveResultPresentation(result, running.name);
|
const presentation = resolveResultPresentation(result, running.name);
|
||||||
sendWhenRuntimeReady((currentPi) => {
|
sendResultWhenRuntimeReady(running.id, (currentPi) => {
|
||||||
currentPi.sendMessage(
|
currentPi.sendMessage(
|
||||||
{
|
{
|
||||||
customType: "subagent_result",
|
customType: "subagent_result",
|
||||||
@@ -2269,14 +2478,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
...(result.stats ? { stats: result.stats } : {}),
|
...(result.stats ? { stats: result.stats } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ triggerTurn: true, deliverAs: "steer" },
|
AUTOMATIC_DELIVERY_OPTIONS,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
updateWidget();
|
updateWidget();
|
||||||
if (running.suppressDelivery) return;
|
if (!shouldDeliverRunningResult(running)) return;
|
||||||
sendWhenRuntimeReady((currentPi) => {
|
sendResultWhenRuntimeReady(running.id, (currentPi) => {
|
||||||
currentPi.sendMessage(
|
currentPi.sendMessage(
|
||||||
{
|
{
|
||||||
customType: "subagent_result",
|
customType: "subagent_result",
|
||||||
@@ -2284,7 +2493,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
display: true,
|
display: true,
|
||||||
details: { name: running.name, task: running.task, error: err?.message },
|
details: { name: running.name, task: running.task, error: err?.message },
|
||||||
},
|
},
|
||||||
{ triggerTurn: true, deliverAs: "steer" },
|
AUTOMATIC_DELIVERY_OPTIONS,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2297,7 +2506,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
text:
|
text:
|
||||||
`Sub-agent "${params.name}" launched and is now running in the background. ` +
|
`Sub-agent "${params.name}" launched and is now running in the background. ` +
|
||||||
`Do NOT generate or assume any results — you have no idea what the sub-agent will do or produce. ` +
|
`Do NOT generate or assume any results — you have no idea what the sub-agent will do or produce. ` +
|
||||||
`The results will be delivered to you automatically as a steer message when the sub-agent finishes. ` +
|
`The results will be delivered to you automatically as a follow-up notification when the sub-agent finishes. ` +
|
||||||
`Until then, move on to other work or tell the user you're waiting.`,
|
`Until then, move on to other work or tell the user you're waiting.`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -2440,12 +2649,12 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
"if it has finished, your message resumes that session and continues it. " +
|
"if it has finished, your message resumes that session and continues it. " +
|
||||||
"`name` and `message` are both required. " +
|
"`name` and `message` are both required. " +
|
||||||
"Steering a running subagent returns immediately with a local acknowledgement and does NOT, by itself, emit a new result. " +
|
"Steering a running subagent returns immediately with a local acknowledgement and does NOT, by itself, emit a new result. " +
|
||||||
"Resuming is a fire-and-forget async call: when the resumed sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up. " +
|
"Resuming is a fire-and-forget async call: when the resumed sub-agent finishes, the harness AUTOMATICALLY delivers its result as a follow-up notification that wakes you up. " +
|
||||||
"DO NOT poll, sleep, tail logs, or read session files to detect completion — the harness handles delivery. " +
|
"DO NOT poll, sleep, tail logs, or read session files to detect completion — the harness handles delivery. " +
|
||||||
"DO NOT fabricate or assume results. After calling, either end your turn or work on other independent tasks.",
|
"DO NOT fabricate or assume results. After calling, either end your turn or work on other independent tasks.",
|
||||||
promptSnippet:
|
promptSnippet:
|
||||||
"Message a subagent by name: steers it if running, resumes it if finished (same name either way). " +
|
"Message a subagent by name: steers it if running, resumes it if finished (same name either way). " +
|
||||||
"`name` and `message` are required. Steering returns immediately; resuming delivers its result later as a steer message. " +
|
"`name` and `message` are required. Steering returns immediately; resuming delivers its result later as a follow-up notification. " +
|
||||||
"Do not poll or fabricate results.",
|
"Do not poll or fabricate results.",
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
name: Type.String({
|
name: Type.String({
|
||||||
@@ -2509,11 +2718,37 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
return muxUnavailableResult();
|
return muxUnavailableResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ensureActive = () => assertLaunchActive(_signal, runtimeToken);
|
||||||
|
ensureActive();
|
||||||
|
|
||||||
// ── Steer a running subagent ──
|
// ── Steer a running subagent ──
|
||||||
// A name that matches a currently-running subagent always steers it.
|
// A completion signal can arrive just before the watcher removes the
|
||||||
const runningMatch = Array.from(runningSubagents.values()).find((r) => r.name === requestedName);
|
// running entry. Let that cleanup settle so a late message resumes the
|
||||||
|
// finished session instead of being typed into a dead pane's shell.
|
||||||
|
let runningMatch = Array.from(runningSubagents.values()).find((r) => r.name === requestedName);
|
||||||
|
if (runningMatch && hasTerminalCompletionSignal(runningMatch)) {
|
||||||
|
await waitForWatcherCleanup(runningMatch, _signal);
|
||||||
|
ensureActive();
|
||||||
|
runningMatch = Array.from(runningSubagents.values()).find((r) => r.name === requestedName);
|
||||||
|
}
|
||||||
if (runningMatch) {
|
if (runningMatch) {
|
||||||
return handleSubagentSteer({ name: requestedName, message: params.message });
|
if (hasTerminalCompletionSignal(runningMatch)) {
|
||||||
|
const err = `Subagent "${requestedName}" is finishing. Retry the message after its completion result arrives.`;
|
||||||
|
return { content: [{ type: "text" as const, text: err }], details: { error: err } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const steerResult = handleSubagentSteer({ name: requestedName, message: params.message });
|
||||||
|
if (!steerResult.details.error || await isSurfaceAlive(runningMatch.surface, _signal)) {
|
||||||
|
return steerResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The child exited between the completion check and terminal send.
|
||||||
|
// Wait for its watcher to release the session, then fall through to
|
||||||
|
// the normal finished-session resume path.
|
||||||
|
await waitForWatcherCleanup(runningMatch, _signal);
|
||||||
|
ensureActive();
|
||||||
|
runningMatch = Array.from(runningSubagents.values()).find((r) => r.name === requestedName);
|
||||||
|
if (runningMatch) return steerResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Resume a finished session by name ──
|
// ── Resume a finished session by name ──
|
||||||
@@ -2569,6 +2804,12 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
return { content: [{ type: "text" as const, text: err }], details: { error: err } };
|
return { content: [{ type: "text" as const, text: err }], details: { error: err } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!reserveResumeName(name)) {
|
||||||
|
const err = `Subagent "${name}" is already being resumed. Wait for its launch acknowledgement before messaging it again.`;
|
||||||
|
return { content: [{ type: "text" as const, text: err }], details: { error: err } };
|
||||||
|
}
|
||||||
|
|
||||||
|
clearStaleSessionSignals(sessionPath);
|
||||||
const resumedSessionId = entry.sessionId ?? getSessionId(sessionPath) ?? requestedName;
|
const resumedSessionId = entry.sessionId ?? getSessionId(sessionPath) ?? requestedName;
|
||||||
|
|
||||||
// Record entry count before resuming so we can extract new messages.
|
// Record entry count before resuming so we can extract new messages.
|
||||||
@@ -2576,10 +2817,11 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
// transcript doesn't block the UI.
|
// transcript doesn't block the UI.
|
||||||
const entryCountBefore = countSessionEntryLines(sessionPath);
|
const entryCountBefore = countSessionEntryLines(sessionPath);
|
||||||
|
|
||||||
const surface = createSurface(name);
|
let surface: string | undefined;
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs()));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
surface = createSurface(name);
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, getShellReadyDelayMs()));
|
||||||
|
ensureActive();
|
||||||
// Build pi resume command
|
// Build pi resume command
|
||||||
const parts = ["pi", "--session", shellEscape(sessionPath)];
|
const parts = ["pi", "--session", shellEscape(sessionPath)];
|
||||||
|
|
||||||
@@ -2656,8 +2898,10 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
.replace(/-+/g, "-")
|
.replace(/-+/g, "-")
|
||||||
.replace(/^-|-$/g, "") || "resume"}-resume-${Date.now()}.sh`,
|
.replace(/^-|-$/g, "") || "resume"}-resume-${Date.now()}.sh`,
|
||||||
);
|
);
|
||||||
|
ensureActive();
|
||||||
sendLongCommand(surface, command, {
|
sendLongCommand(surface, command, {
|
||||||
scriptPath: launchScriptFile,
|
scriptPath: launchScriptFile,
|
||||||
|
replaceShell: true,
|
||||||
scriptPreamble: [
|
scriptPreamble: [
|
||||||
`# Subagent resume script for ${name}`,
|
`# Subagent resume script for ${name}`,
|
||||||
`# Generated: ${new Date().toISOString()}`,
|
`# Generated: ${new Date().toISOString()}`,
|
||||||
@@ -2666,6 +2910,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
...(resumeMsgFile ? [`# Resume message file: ${resumeMsgFile}`] : []),
|
...(resumeMsgFile ? [`# Resume message file: ${resumeMsgFile}`] : []),
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
});
|
});
|
||||||
|
ensureActive();
|
||||||
|
|
||||||
// Register as a running subagent for widget tracking
|
// Register as a running subagent for widget tracking
|
||||||
const running: RunningSubagent = {
|
const running: RunningSubagent = {
|
||||||
@@ -2683,6 +2928,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
source: "pi",
|
source: "pi",
|
||||||
startTimeMs: startTime,
|
startTimeMs: startTime,
|
||||||
}),
|
}),
|
||||||
|
parentGeneration: sharedRuntime.parentGeneration,
|
||||||
};
|
};
|
||||||
runningSubagents.set(id, running);
|
runningSubagents.set(id, running);
|
||||||
startWidgetRefresh();
|
startWidgetRefresh();
|
||||||
@@ -2695,7 +2941,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
watchSubagent(running, watcherAbort.signal)
|
watchSubagent(running, watcherAbort.signal)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
updateWidget();
|
updateWidget();
|
||||||
if (running.suppressDelivery) return;
|
if (!shouldDeliverRunningResult(running)) return;
|
||||||
|
|
||||||
const allEntries = getNewEntries(sessionPath, entryCountBefore);
|
const allEntries = getNewEntries(sessionPath, entryCountBefore);
|
||||||
const summary = findLastAssistantMessage(allEntries) ??
|
const summary = findLastAssistantMessage(allEntries) ??
|
||||||
@@ -2709,7 +2955,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
name,
|
name,
|
||||||
);
|
);
|
||||||
|
|
||||||
sendWhenRuntimeReady((currentPi) => {
|
sendResultWhenRuntimeReady(running.id, (currentPi) => {
|
||||||
currentPi.sendMessage(
|
currentPi.sendMessage(
|
||||||
{
|
{
|
||||||
customType: "subagent_result",
|
customType: "subagent_result",
|
||||||
@@ -2725,14 +2971,14 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ triggerTurn: true, deliverAs: "steer" },
|
AUTOMATIC_DELIVERY_OPTIONS,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
updateWidget();
|
updateWidget();
|
||||||
if (running.suppressDelivery) return;
|
if (!shouldDeliverRunningResult(running)) return;
|
||||||
sendWhenRuntimeReady((currentPi) => {
|
sendResultWhenRuntimeReady(running.id, (currentPi) => {
|
||||||
currentPi.sendMessage(
|
currentPi.sendMessage(
|
||||||
{
|
{
|
||||||
customType: "subagent_result",
|
customType: "subagent_result",
|
||||||
@@ -2740,7 +2986,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
display: true,
|
display: true,
|
||||||
details: { name, error: err?.message },
|
details: { name, error: err?.message },
|
||||||
},
|
},
|
||||||
{ triggerTurn: true, deliverAs: "steer" },
|
AUTOMATIC_DELIVERY_OPTIONS,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2758,12 +3004,16 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
runningSubagents.delete(id);
|
runningSubagents.delete(id);
|
||||||
try {
|
if (surface) {
|
||||||
closeSurface(surface);
|
try {
|
||||||
} catch {
|
closeSurface(surface);
|
||||||
// Preserve the resume error; cleanup is best-effort.
|
} catch {
|
||||||
|
// Preserve the resume error; cleanup is best-effort.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
resumingNames.delete(name);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -281,11 +281,13 @@ async function readScreenWithCli(
|
|||||||
surface: string,
|
surface: string,
|
||||||
lines: number,
|
lines: number,
|
||||||
timeout = ASYNC_READ_SCREEN_TIMEOUT_MS,
|
timeout = ASYNC_READ_SCREEN_TIMEOUT_MS,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const { stdout } = await execFileAsync(cli, readScreenArgs(surface, lines), {
|
const { stdout } = await execFileAsync(cli, readScreenArgs(surface, lines), {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
timeout,
|
timeout,
|
||||||
killSignal: "SIGKILL",
|
killSignal: "SIGKILL",
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
return parseReadTail(stdout);
|
return parseReadTail(stdout);
|
||||||
}
|
}
|
||||||
@@ -294,6 +296,15 @@ export async function readScreenAsync(surface: string, lines = 50): Promise<stri
|
|||||||
return readScreenWithCli(requireOrca(), surface, lines);
|
return readScreenWithCli(requireOrca(), surface, lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function isSurfaceAlive(surface: string, signal?: AbortSignal): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await readScreenWithCli(requireOrca(), surface, 1, ASYNC_READ_SCREEN_TIMEOUT_MS, signal);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const __orcaTest__ = { readScreenWithCli };
|
export const __orcaTest__ = { readScreenWithCli };
|
||||||
|
|
||||||
export function closeSurface(surface: string): void {
|
export function closeSurface(surface: string): void {
|
||||||
@@ -301,7 +312,11 @@ export function closeSurface(surface: string): void {
|
|||||||
const args = ["terminal", "close", "--terminal", surface];
|
const args = ["terminal", "close", "--terminal", surface];
|
||||||
if (tabByHandle.has(surface)) args.push("--tab");
|
if (tabByHandle.has(surface)) args.push("--tab");
|
||||||
args.push("--json");
|
args.push("--json");
|
||||||
const output = execFileSync(cli, args, { encoding: "utf8" });
|
try {
|
||||||
parseResponse("terminal close", output);
|
const output = execFileSync(cli, args, { encoding: "utf8" });
|
||||||
|
parseResponse("terminal close", output);
|
||||||
|
} catch {
|
||||||
|
// Replaced-shell launches may close their own terminal before cleanup.
|
||||||
|
}
|
||||||
tabByHandle.delete(surface);
|
tabByHandle.delete(surface);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
import { Box, Text } from "@earendil-works/pi-tui";
|
import { Box, Text } from "@earendil-works/pi-tui";
|
||||||
import { Type } from "typebox";
|
import { Type } from "typebox";
|
||||||
import { writeFileSync } from "node:fs";
|
import { renameSync, writeFileSync } from "node:fs";
|
||||||
import { createSubagentActivityRecorder } from "./activity.ts";
|
import { createSubagentActivityRecorder } from "./activity.ts";
|
||||||
|
|
||||||
export function shouldMarkUserTookOver(agentStarted: boolean): boolean {
|
export function shouldMarkUserTookOver(agentStarted: boolean): boolean {
|
||||||
@@ -373,7 +373,10 @@ export default function (pi: ExtensionAPI) {
|
|||||||
agent: process.env.PI_SUBAGENT_AGENT ?? "",
|
agent: process.env.PI_SUBAGENT_AGENT ?? "",
|
||||||
question: params.question,
|
question: params.question,
|
||||||
};
|
};
|
||||||
writeFileSync(`${sessionFile}.ask`, JSON.stringify(askData));
|
const askFile = `${sessionFile}.ask`;
|
||||||
|
const tempAskFile = `${askFile}.tmp-${process.pid}-${Math.random().toString(16).slice(2, 8)}`;
|
||||||
|
writeFileSync(tempAskFile, JSON.stringify(askData));
|
||||||
|
renameSync(tempAskFile, askFile);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type SurfaceBackend = {
|
|||||||
sendCommand(surface: string, command: string): void;
|
sendCommand(surface: string, command: string): void;
|
||||||
readScreen(surface: string, lines?: number): string;
|
readScreen(surface: string, lines?: number): string;
|
||||||
readScreenAsync(surface: string, lines?: number): Promise<string>;
|
readScreenAsync(surface: string, lines?: number): Promise<string>;
|
||||||
|
isSurfaceAlive(surface: string, signal?: AbortSignal): Promise<boolean>;
|
||||||
closeSurface(surface: string): void;
|
closeSurface(surface: string): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,6 +88,10 @@ export function readScreenAsync(surface: string, lines = 50): Promise<string> {
|
|||||||
return getBackend().readScreenAsync(surface, lines);
|
return getBackend().readScreenAsync(surface, lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isSurfaceAlive(surface: string, signal?: AbortSignal): Promise<boolean> {
|
||||||
|
return getBackend().isSurfaceAlive(surface, signal);
|
||||||
|
}
|
||||||
|
|
||||||
export function closeSurface(surface: string): void {
|
export function closeSurface(surface: string): void {
|
||||||
getBackend().closeSurface(surface);
|
getBackend().closeSurface(surface);
|
||||||
}
|
}
|
||||||
@@ -106,7 +111,7 @@ export function wrapWithCompletionFile(command: string, completionFile: string):
|
|||||||
export function sendLongCommand(
|
export function sendLongCommand(
|
||||||
surface: string,
|
surface: string,
|
||||||
command: string,
|
command: string,
|
||||||
options?: { scriptPath?: string; scriptPreamble?: string },
|
options?: { scriptPath?: string; scriptPreamble?: string; replaceShell?: boolean },
|
||||||
): string {
|
): string {
|
||||||
const scriptPath =
|
const scriptPath =
|
||||||
options?.scriptPath ??
|
options?.scriptPath ??
|
||||||
@@ -122,7 +127,8 @@ export function sendLongCommand(
|
|||||||
scriptParts.push(command);
|
scriptParts.push(command);
|
||||||
|
|
||||||
writeFileSync(scriptPath, scriptParts.join("\n") + "\n", { mode: 0o755 });
|
writeFileSync(scriptPath, scriptParts.join("\n") + "\n", { mode: 0o755 });
|
||||||
sendCommand(surface, `bash ${shellEscape(scriptPath)}`);
|
const launch = `${options?.replaceShell ? "exec " : ""}bash ${shellEscape(scriptPath)}`;
|
||||||
|
sendCommand(surface, launch);
|
||||||
return scriptPath;
|
return scriptPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +165,10 @@ type PollOptions = {
|
|||||||
sentinelFile?: string;
|
sentinelFile?: string;
|
||||||
completionFile?: string;
|
completionFile?: string;
|
||||||
onTick?: (elapsed: number) => void;
|
onTick?: (elapsed: number) => void;
|
||||||
|
onExitDetected?: () => void;
|
||||||
|
livenessCheck?: (surface: string, signal?: AbortSignal) => Promise<boolean>;
|
||||||
|
livenessIntervalMs?: number;
|
||||||
|
livenessFailureThreshold?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Avoid launching a terminal CLI reader when a local completion signal is authoritative. */
|
/** Avoid launching a terminal CLI reader when a local completion signal is authoritative. */
|
||||||
@@ -177,6 +187,11 @@ export async function pollForExit(
|
|||||||
readSurface: (surface: string, lines?: number) => Promise<string> = readScreenAsync,
|
readSurface: (surface: string, lines?: number) => Promise<string> = readScreenAsync,
|
||||||
): Promise<PollResult> {
|
): Promise<PollResult> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
const livenessCheck = options.livenessCheck ?? isSurfaceAlive;
|
||||||
|
const livenessIntervalMs = options.livenessIntervalMs ?? 5_000;
|
||||||
|
const livenessFailureThreshold = options.livenessFailureThreshold ?? 3;
|
||||||
|
let nextLivenessCheck = start + livenessIntervalMs;
|
||||||
|
let livenessFailures = 0;
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
if (signal.aborted) throw new Error("Aborted while waiting for subagent to finish");
|
if (signal.aborted) throw new Error("Aborted while waiting for subagent to finish");
|
||||||
@@ -186,6 +201,7 @@ export async function pollForExit(
|
|||||||
const exitFile = `${options.sessionFile}.exit`;
|
const exitFile = `${options.sessionFile}.exit`;
|
||||||
if (existsSync(exitFile)) {
|
if (existsSync(exitFile)) {
|
||||||
const result = interpretExitSidecar(JSON.parse(readFileSync(exitFile, "utf8")));
|
const result = interpretExitSidecar(JSON.parse(readFileSync(exitFile, "utf8")));
|
||||||
|
options.onExitDetected?.();
|
||||||
rmSync(exitFile, { force: true });
|
rmSync(exitFile, { force: true });
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -195,6 +211,7 @@ export async function pollForExit(
|
|||||||
if (options.sentinelFile) {
|
if (options.sentinelFile) {
|
||||||
try {
|
try {
|
||||||
if (existsSync(options.sentinelFile)) {
|
if (existsSync(options.sentinelFile)) {
|
||||||
|
options.onExitDetected?.();
|
||||||
return { reason: "sentinel", exitCode: 0 };
|
return { reason: "sentinel", exitCode: 0 };
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -203,7 +220,10 @@ export async function pollForExit(
|
|||||||
if (options.completionFile) {
|
if (options.completionFile) {
|
||||||
try {
|
try {
|
||||||
const result = readCompletionFile(options.completionFile);
|
const result = readCompletionFile(options.completionFile);
|
||||||
if (result) return result;
|
if (result) {
|
||||||
|
options.onExitDetected?.();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,10 +231,24 @@ export async function pollForExit(
|
|||||||
try {
|
try {
|
||||||
const screen = await readSurface(surface, 5);
|
const screen = await readSurface(surface, 5);
|
||||||
const match = screen.match(/__SUBAGENT_DONE_(\d+)__/);
|
const match = screen.match(/__SUBAGENT_DONE_(\d+)__/);
|
||||||
if (match) return { reason: "sentinel", exitCode: Number.parseInt(match[1], 10) };
|
if (match) {
|
||||||
|
options.onExitDetected?.();
|
||||||
|
return { reason: "sentinel", exitCode: Number.parseInt(match[1], 10) };
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Surface reads are a best-effort fallback when no completion file is available.
|
// Surface reads are a best-effort fallback when no completion file is available.
|
||||||
}
|
}
|
||||||
|
} else if (Date.now() >= nextLivenessCheck) {
|
||||||
|
nextLivenessCheck = Date.now() + livenessIntervalMs;
|
||||||
|
try {
|
||||||
|
livenessFailures = await livenessCheck(surface, signal) ? 0 : livenessFailures + 1;
|
||||||
|
} catch {
|
||||||
|
livenessFailures += 1;
|
||||||
|
}
|
||||||
|
if (signal.aborted) throw new Error("Aborted while checking subagent surface liveness");
|
||||||
|
if (livenessFailures >= livenessFailureThreshold) {
|
||||||
|
throw new Error("Subagent terminal surface closed before completion.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsed = Math.floor((Date.now() - start) / 1000);
|
const elapsed = Math.floor((Date.now() - start) / 1000);
|
||||||
|
|||||||
@@ -112,8 +112,27 @@ export async function readScreenAsync(surface: string, lines = 50): Promise<stri
|
|||||||
return stdout;
|
return stdout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function isSurfaceAlive(surface: string, signal?: AbortSignal): Promise<boolean> {
|
||||||
|
requireTmux();
|
||||||
|
try {
|
||||||
|
await execFileAsync("tmux", ["display-message", "-p", "-t", surface, "#{pane_id}"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 5_000,
|
||||||
|
killSignal: "SIGKILL",
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function closeSurface(surface: string): void {
|
export function closeSurface(surface: string): void {
|
||||||
requireTmux();
|
requireTmux();
|
||||||
execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8" });
|
try {
|
||||||
|
execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8" });
|
||||||
|
} catch {
|
||||||
|
// Replaced-shell launches may close their own pane before cleanup.
|
||||||
|
}
|
||||||
rebalanceSurfaces();
|
rebalanceSurfaces();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
name: test-default-autonomous
|
||||||
|
description: Integration test agent with omitted auto-exit
|
||||||
|
model: anthropic/claude-haiku-4-5
|
||||||
|
tools: bash
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a test agent. Run the requested bash command immediately, then report completion concisely. Do not ask questions or do anything else.
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
import { describe, it, before, after } from "node:test";
|
import { describe, it, before, after } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
import {
|
import {
|
||||||
getAvailableBackends,
|
getAvailableBackends,
|
||||||
createTestEnv,
|
createTestEnv,
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
trackTempFile,
|
trackTempFile,
|
||||||
readScreen,
|
readScreen,
|
||||||
sendCommand,
|
sendCommand,
|
||||||
|
shellEscape,
|
||||||
PI_TIMEOUT,
|
PI_TIMEOUT,
|
||||||
type TestEnv,
|
type TestEnv,
|
||||||
} from "./harness.ts";
|
} from "./harness.ts";
|
||||||
@@ -202,42 +204,115 @@ for (const backend of backends) {
|
|||||||
|
|
||||||
// ── Parallel subagent spawn ──
|
// ── Parallel subagent spawn ──
|
||||||
|
|
||||||
it("spawns two subagents in parallel and both complete", async () => {
|
it("delivers two staggered sibling launches exactly once after the parent becomes idle", async () => {
|
||||||
const id = uniqueId();
|
const id = uniqueId();
|
||||||
|
const nameA = `ParaA-${id}`;
|
||||||
|
const nameB = `ParaB-${id}`;
|
||||||
const fileA = `/tmp/pi-integ-para-${id}-a.txt`;
|
const fileA = `/tmp/pi-integ-para-${id}-a.txt`;
|
||||||
const fileB = `/tmp/pi-integ-para-${id}-b.txt`;
|
const fileB = `/tmp/pi-integ-para-${id}-b.txt`;
|
||||||
|
const parentWorkFile = `/tmp/pi-integ-para-${id}-parent.txt`;
|
||||||
|
const parentSession = join(env.dir, `parallel-parent-${id}.jsonl`);
|
||||||
trackTempFile(env, fileA);
|
trackTempFile(env, fileA);
|
||||||
trackTempFile(env, fileB);
|
trackTempFile(env, fileB);
|
||||||
|
trackTempFile(env, parentWorkFile);
|
||||||
|
|
||||||
const surface = createTrackedSurface(env, `parallel-${id}`);
|
const surface = createTrackedSurface(env, `parallel-${id}`);
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
|
|
||||||
const task = [
|
const task = [
|
||||||
`You must call the subagent tool TWICE. Make both calls before waiting for results.`,
|
`Call the subagent tool twice in ONE assistant response so Pi executes them as parallel sibling tool calls.`,
|
||||||
|
`Use these exact parameters:`,
|
||||||
``,
|
``,
|
||||||
`First call:`,
|
`First call:`,
|
||||||
` name: "ParaA-${id}"`,
|
` name: "${nameA}"`,
|
||||||
` agent: "test-echo"`,
|
` agent: "test-default-autonomous"`,
|
||||||
` task: "Run: echo 'DONE_A_${id}' > '${fileA}'"`,
|
` cwd: "${env.dir}"`,
|
||||||
|
` task: "Run: sleep 6; echo 'DONE_A_${id}' > '${fileA}'"`,
|
||||||
``,
|
``,
|
||||||
`Second call:`,
|
`Second call:`,
|
||||||
` name: "ParaB-${id}"`,
|
` name: "${nameB}"`,
|
||||||
` agent: "test-echo"`,
|
` agent: "test-default-autonomous"`,
|
||||||
` task: "Run: echo 'DONE_B_${id}' > '${fileB}'"`,
|
` cwd: "${env.dir}"`,
|
||||||
|
` task: "Run: sleep 12; echo 'DONE_B_${id}' > '${fileB}'"`,
|
||||||
``,
|
``,
|
||||||
`Call both subagent tools NOW, do not wait between them.`,
|
`After both launch acknowledgements, use bash yourself to write PARENT_WORK_${id} to '${parentWorkFile}', then say you are waiting and stop.`,
|
||||||
|
`Never call subagent_message. When the first result arrives, acknowledge it and stop while the other child runs.`,
|
||||||
|
`When both results have arrived, say PARALLEL_IDLE_COMPLETE_${id} exactly once.`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
startPi(surface, env.dir, task);
|
startPi(surface, env.dir, task, {
|
||||||
|
extraArgs: `--session ${shellEscape(parentSession)}`,
|
||||||
|
});
|
||||||
|
|
||||||
// Both marker files should appear
|
await waitForFile(parentWorkFile, PI_TIMEOUT, /PARENT_WORK_/);
|
||||||
const [contentA, contentB] = await Promise.all([
|
const screen = await waitForScreen(
|
||||||
waitForFile(fileA, PI_TIMEOUT, /DONE_A/),
|
surface,
|
||||||
waitForFile(fileB, PI_TIMEOUT, /DONE_B/),
|
new RegExp(`^ PARALLEL_IDLE_COMPLETE_${id}\\s*$`, "m"),
|
||||||
]);
|
PI_TIMEOUT,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
screen.match(new RegExp(`^ PARALLEL_IDLE_COMPLETE_${id}\\s*$`, "gm"))?.length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.match(await waitForFile(fileA, PI_TIMEOUT, /DONE_A_/), new RegExp(`DONE_A_${id}`));
|
||||||
|
assert.match(await waitForFile(fileB, PI_TIMEOUT, /DONE_B_/), new RegExp(`DONE_B_${id}`));
|
||||||
|
|
||||||
assert.ok(contentA.includes(`DONE_A_${id}`), `File A should contain marker`);
|
const entries = readFileSync(parentSession, "utf8")
|
||||||
assert.ok(contentB.includes(`DONE_B_${id}`), `File B should contain marker`);
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => JSON.parse(line));
|
||||||
|
const siblingCall = entries.find((entry) => {
|
||||||
|
const calls = entry.message?.role === "assistant"
|
||||||
|
? entry.message.content?.filter((part: any) => part.type === "toolCall" && part.name === "subagent")
|
||||||
|
: [];
|
||||||
|
return calls?.length === 2;
|
||||||
|
});
|
||||||
|
assert.ok(siblingCall, "both wrapped launches should reach Pi as sibling subagent calls");
|
||||||
|
|
||||||
|
const calls = siblingCall.message.content.filter(
|
||||||
|
(part: any) => part.type === "toolCall" && part.name === "subagent",
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls.map((call: any) => call.arguments.name), [nameA, nameB]);
|
||||||
|
assert.deepEqual(calls.map((call: any) => call.arguments.cwd), [env.dir, env.dir]);
|
||||||
|
|
||||||
|
const acknowledgements = entries.filter(
|
||||||
|
(entry) => entry.message?.role === "toolResult" && entry.message.toolName === "subagent",
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
acknowledgements.map((entry) => entry.message.details?.name).sort(),
|
||||||
|
[nameA, nameB].sort(),
|
||||||
|
);
|
||||||
|
for (const acknowledgement of acknowledgements) {
|
||||||
|
assert.equal(acknowledgement.message.details?.status, "started");
|
||||||
|
const childHeader = JSON.parse(
|
||||||
|
readFileSync(acknowledgement.message.details.sessionFile, "utf8").split("\n")[0],
|
||||||
|
);
|
||||||
|
assert.equal(childHeader.cwd, env.dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
const completions = entries.filter(
|
||||||
|
(entry) => entry.type === "custom_message" && entry.customType === "subagent_result",
|
||||||
|
);
|
||||||
|
assert.deepEqual(completions.map((entry) => entry.details?.name).sort(), [nameA, nameB].sort());
|
||||||
|
assert.equal(completions.length, 2, "each child result should be delivered exactly once");
|
||||||
|
assert.ok(completions.every((entry) => entry.details?.exitCode === 0));
|
||||||
|
|
||||||
|
const idleBeforeCompletion = entries.findIndex((entry, index) => {
|
||||||
|
if (index <= entries.indexOf(acknowledgements.at(-1))) return false;
|
||||||
|
if (entry.message?.role !== "assistant" || entry.message.stopReason !== "stop") return false;
|
||||||
|
return completions.every((completion) => Date.parse(completion.timestamp) > Date.parse(entry.timestamp));
|
||||||
|
});
|
||||||
|
assert.notEqual(idleBeforeCompletion, -1, "the parent should settle before either child completes");
|
||||||
|
for (const completion of completions) {
|
||||||
|
assert.ok(
|
||||||
|
entries.some(
|
||||||
|
(entry) => entry.message?.role === "assistant"
|
||||||
|
&& Date.parse(entry.timestamp) > Date.parse(completion.timestamp),
|
||||||
|
),
|
||||||
|
`${completion.details?.name} should trigger a parent turn while idle`,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Fork mode ──
|
// ── Fork mode ──
|
||||||
|
|||||||
@@ -158,13 +158,16 @@ async function emitMockEvent(
|
|||||||
|
|
||||||
type SharedRuntimeTestState = {
|
type SharedRuntimeTestState = {
|
||||||
runningSubagents: Map<string, unknown>;
|
runningSubagents: Map<string, unknown>;
|
||||||
|
resumingNames: Set<string>;
|
||||||
latestCtx: unknown;
|
latestCtx: unknown;
|
||||||
latestPi: unknown;
|
latestPi: unknown;
|
||||||
widgetInterval: ReturnType<typeof setInterval> | null;
|
widgetInterval: ReturnType<typeof setInterval> | null;
|
||||||
statusInterval: ReturnType<typeof setInterval> | null;
|
statusInterval: ReturnType<typeof setInterval> | null;
|
||||||
standaloneWidgetSyncScheduled: boolean;
|
standaloneWidgetSyncScheduled: boolean;
|
||||||
pendingDeliveries: { length: number };
|
pendingDeliveries: { length: number };
|
||||||
|
resultDeliveryStates: Map<string, "queued" | "delivered">;
|
||||||
deliveryFlushScheduled: boolean;
|
deliveryFlushScheduled: boolean;
|
||||||
|
parentGeneration: number;
|
||||||
activeRuntimeToken: symbol | null;
|
activeRuntimeToken: symbol | null;
|
||||||
registeredRuntimeToken: symbol | null;
|
registeredRuntimeToken: symbol | null;
|
||||||
statusWidgetClaim: unknown;
|
statusWidgetClaim: unknown;
|
||||||
@@ -178,11 +181,14 @@ function resetSharedRuntimeForTest(shared: SharedRuntimeTestState): void {
|
|||||||
shared.statusInterval = null;
|
shared.statusInterval = null;
|
||||||
shared.standaloneWidgetSyncScheduled = false;
|
shared.standaloneWidgetSyncScheduled = false;
|
||||||
shared.runningSubagents.clear();
|
shared.runningSubagents.clear();
|
||||||
|
shared.resumingNames.clear();
|
||||||
shared.latestCtx = null;
|
shared.latestCtx = null;
|
||||||
shared.latestPi = null;
|
shared.latestPi = null;
|
||||||
shared.statusWidgetClaim = null;
|
shared.statusWidgetClaim = null;
|
||||||
shared.pendingDeliveries.length = 0;
|
shared.pendingDeliveries.length = 0;
|
||||||
|
shared.resultDeliveryStates.clear();
|
||||||
shared.deliveryFlushScheduled = false;
|
shared.deliveryFlushScheduled = false;
|
||||||
|
shared.parentGeneration = 0;
|
||||||
shared.activeRuntimeToken = null;
|
shared.activeRuntimeToken = null;
|
||||||
shared.registeredRuntimeToken = null;
|
shared.registeredRuntimeToken = null;
|
||||||
}
|
}
|
||||||
@@ -1201,28 +1207,53 @@ describe("subagent discovery", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolveEffectiveInteractive defaults to the inverse of auto-exit", () => {
|
it("defaults unspecified launch behavior to autonomous while preserving explicit interactive profiles", () => {
|
||||||
// Autonomous agents (auto-exit: true) are NOT interactive — parent gets stall pings.
|
assert.equal(testApi.resolveEffectiveAutoExit({ autoExit: true }), true);
|
||||||
|
assert.equal(testApi.resolveEffectiveAutoExit({ autoExit: false }), false);
|
||||||
|
assert.equal(testApi.resolveEffectiveAutoExit({}), true);
|
||||||
|
assert.equal(testApi.resolveEffectiveAutoExit(null), true);
|
||||||
|
assert.equal(testApi.autoExitEnvAssignment(true), "PI_SUBAGENT_AUTO_EXIT=1");
|
||||||
|
assert.equal(testApi.autoExitEnvAssignment(false), "PI_SUBAGENT_AUTO_EXIT=0");
|
||||||
|
assert.match(testApi.resolveClaudeSentinelFile("child", true), /pi-claude-child-done$/);
|
||||||
|
assert.equal(testApi.resolveClaudeSentinelFile("child", false), undefined);
|
||||||
|
|
||||||
|
// Autonomous agents are not user-driven, so parent status transitions stay enabled.
|
||||||
assert.equal(
|
assert.equal(
|
||||||
testApi.resolveEffectiveInteractive({ name: "A", task: "T" }, { autoExit: true }),
|
testApi.resolveEffectiveInteractive({ name: "A", task: "T" }, { autoExit: true }),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
// Agents without auto-exit ARE interactive — parent does not receive status transition pings.
|
// Explicitly non-auto-exit profiles remain interactive.
|
||||||
assert.equal(
|
assert.equal(
|
||||||
testApi.resolveEffectiveInteractive({ name: "A", task: "T" }, { autoExit: false }),
|
testApi.resolveEffectiveInteractive({ name: "A", task: "T" }, { autoExit: false }),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
// Missing auto-exit follows the fire-and-forget tool contract.
|
||||||
assert.equal(
|
assert.equal(
|
||||||
testApi.resolveEffectiveInteractive({ name: "A", task: "T" }, {}),
|
testApi.resolveEffectiveInteractive({ name: "A", task: "T" }, {}),
|
||||||
true,
|
false,
|
||||||
);
|
);
|
||||||
// Bare spawn with no agent defs (e.g. /iterate fork) is interactive by default.
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
testApi.resolveEffectiveInteractive({ name: "A", task: "T" }, null),
|
testApi.resolveEffectiveInteractive({ name: "A", task: "T" }, null),
|
||||||
true,
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves relative launch cwd from the parent session rather than the process", () => {
|
||||||
|
const resolved = testApi.resolveSubagentPaths(
|
||||||
|
{ agent: "worker", task: "T", cwd: "roles/reviewer" },
|
||||||
|
{},
|
||||||
|
"/workspace/current-session",
|
||||||
|
);
|
||||||
|
assert.equal(resolved.effectiveCwd, "/workspace/current-session/roles/reviewer");
|
||||||
|
|
||||||
|
const inherited = testApi.resolveSubagentPaths(
|
||||||
|
{ agent: "worker", task: "T" },
|
||||||
|
{},
|
||||||
|
"/workspace/current-session",
|
||||||
|
);
|
||||||
|
assert.equal(inherited.effectiveCwd, null);
|
||||||
|
});
|
||||||
|
|
||||||
it("resolveEffectiveInteractive honors explicit frontmatter over the auto-exit default", () => {
|
it("resolveEffectiveInteractive honors explicit frontmatter over the auto-exit default", () => {
|
||||||
// Autonomous agent that still wants to be treated as interactive.
|
// Autonomous agent that still wants to be treated as interactive.
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -1851,6 +1882,42 @@ describe("surface exit signals", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("terminates after repeated liveness failures when a completion-file surface is closed", async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
pollForExit(
|
||||||
|
"closed-surface",
|
||||||
|
new AbortController().signal,
|
||||||
|
{
|
||||||
|
interval: 1,
|
||||||
|
completionFile: "/tmp/nonexistent-subagent-completion",
|
||||||
|
livenessCheck: async () => false,
|
||||||
|
livenessIntervalMs: 0,
|
||||||
|
livenessFailureThreshold: 2,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
/surface closed before completion/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts an in-flight completion-file liveness probe", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const polling = pollForExit(
|
||||||
|
"blocked-surface",
|
||||||
|
controller.signal,
|
||||||
|
{
|
||||||
|
interval: 1,
|
||||||
|
completionFile: "/tmp/nonexistent-subagent-completion",
|
||||||
|
livenessIntervalMs: 0,
|
||||||
|
livenessCheck: async (_surface: string, signal?: AbortSignal) =>
|
||||||
|
await new Promise<boolean>((_resolve, reject) => {
|
||||||
|
signal?.addEventListener("abort", () => reject(new Error("probe aborted")), { once: true });
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setTimeout(() => controller.abort(), 10);
|
||||||
|
await assert.rejects(polling, /aborted/i);
|
||||||
|
});
|
||||||
|
|
||||||
it("bounds an asynchronous Orca screen read and kills its CLI process", async () => {
|
it("bounds an asynchronous Orca screen read and kills its CLI process", async () => {
|
||||||
const dir = createTestDir();
|
const dir = createTestDir();
|
||||||
const pidFile = join(dir, "reader.pid");
|
const pidFile = join(dir, "reader.pid");
|
||||||
@@ -1966,6 +2033,182 @@ describe("reload lifecycle", () => {
|
|||||||
beforeEach(() => resetSharedRuntimeForTest(shared));
|
beforeEach(() => resetSharedRuntimeForTest(shared));
|
||||||
after(() => resetSharedRuntimeForTest(shared));
|
after(() => resetSharedRuntimeForTest(shared));
|
||||||
|
|
||||||
|
it("delivers parallel terminal results exactly once across active and temporarily unbound runtimes", async () => {
|
||||||
|
const activeRuntime = createMockExtensionApi();
|
||||||
|
(subagentsModule as any).default(activeRuntime.api);
|
||||||
|
await emitMockEvent(activeRuntime, "session_start", { reason: "startup" }, ctx);
|
||||||
|
|
||||||
|
const deliver = (name: string) => (pi: any) => {
|
||||||
|
pi.sendMessage({ customType: "subagent_result", content: name, display: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(testApi.sendResultWhenRuntimeReady("child-a", deliver("child-a")), true);
|
||||||
|
assert.equal(testApi.sendResultWhenRuntimeReady("child-a", deliver("child-a duplicate")), false);
|
||||||
|
|
||||||
|
let transientAttempts = 0;
|
||||||
|
assert.equal(testApi.sendResultWhenRuntimeReady("child-retry", (pi: any) => {
|
||||||
|
transientAttempts += 1;
|
||||||
|
if (transientAttempts === 1) throw new Error("stale runtime");
|
||||||
|
pi.sendMessage({ customType: "subagent_result", content: "child-retry", display: true });
|
||||||
|
}), true);
|
||||||
|
assert.equal(testApi.sendResultWhenRuntimeReady("child-independent", deliver("child-independent")), true);
|
||||||
|
assert.deepEqual(
|
||||||
|
activeRuntime.sentMessages.map(({ message }) => message.content),
|
||||||
|
["child-a", "child-independent"],
|
||||||
|
"one failing delivery must not block its sibling",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Registration happens before session_start during reload. Results in this
|
||||||
|
// gap must queue rather than use the still-bound old ExtensionAPI.
|
||||||
|
const freshRuntime = createMockExtensionApi();
|
||||||
|
(subagentsModule as any).default(freshRuntime.api);
|
||||||
|
assert.equal(testApi.sendResultWhenRuntimeReady("child-b", deliver("child-b")), true);
|
||||||
|
assert.equal(activeRuntime.sentMessages.length, 2, "the stale runtime must not receive child-b");
|
||||||
|
|
||||||
|
await emitMockEvent(activeRuntime, "session_shutdown", { reason: "reload" }, ctx);
|
||||||
|
assert.equal(testApi.sendResultWhenRuntimeReady("child-c", deliver("child-c failed")), true);
|
||||||
|
assert.equal(testApi.sendResultWhenRuntimeReady("child-b", deliver("child-b duplicate")), false);
|
||||||
|
assert.equal(shared.pendingDeliveries.length, 3);
|
||||||
|
|
||||||
|
await emitMockEvent(freshRuntime, "session_start", { reason: "reload" }, ctx);
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
freshRuntime.sentMessages.map(({ message }) => message.content),
|
||||||
|
["child-retry", "child-b", "child-c failed"],
|
||||||
|
);
|
||||||
|
testApi.flushPendingDeliveries();
|
||||||
|
assert.equal(freshRuntime.sentMessages.length, 3);
|
||||||
|
assert.equal(testApi.sendResultWhenRuntimeReady("child-c", deliver("post-flush duplicate")), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds delivered result tombstones without evicting queued results", () => {
|
||||||
|
shared.latestPi = { sendMessage() {} };
|
||||||
|
shared.activeRuntimeToken = Symbol.for("test-runtime");
|
||||||
|
shared.registeredRuntimeToken = shared.activeRuntimeToken;
|
||||||
|
for (let index = 0; index < 1_100; index += 1) {
|
||||||
|
assert.equal(testApi.sendResultWhenRuntimeReady(`finished-${index}`, () => {}), true);
|
||||||
|
}
|
||||||
|
assert.ok(shared.resultDeliveryStates.size <= 1_024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects launches after cancellation or runtime replacement", () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const runtimeToken = Symbol("runtime");
|
||||||
|
shared.activeRuntimeToken = runtimeToken;
|
||||||
|
shared.registeredRuntimeToken = runtimeToken;
|
||||||
|
assert.doesNotThrow(() => testApi.assertLaunchActive(controller.signal, runtimeToken));
|
||||||
|
|
||||||
|
controller.abort();
|
||||||
|
assert.throws(
|
||||||
|
() => testApi.assertLaunchActive(controller.signal, runtimeToken),
|
||||||
|
/parent session ended/i,
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentController = new AbortController();
|
||||||
|
shared.registeredRuntimeToken = Symbol("replacement");
|
||||||
|
assert.throws(
|
||||||
|
() => testApi.assertLaunchActive(currentController.signal, runtimeToken),
|
||||||
|
/parent session ended/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a completion from a replaced parent even after its watcher left the running map", () => {
|
||||||
|
const completed = {
|
||||||
|
id: "old-child",
|
||||||
|
name: "old-child",
|
||||||
|
task: "done",
|
||||||
|
surface: "old-pane",
|
||||||
|
startTime: 0,
|
||||||
|
sessionFile: "/tmp/old-child.jsonl",
|
||||||
|
interactive: false,
|
||||||
|
parentGeneration: shared.parentGeneration,
|
||||||
|
statusState: createStatusState({ source: "pi", startTimeMs: 0 }),
|
||||||
|
};
|
||||||
|
assert.equal(testApi.shouldDeliverRunningResult(completed), true);
|
||||||
|
shared.parentGeneration += 1;
|
||||||
|
assert.equal(testApi.shouldDeliverRunningResult(completed), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a queued subagent question after parent replacement", () => {
|
||||||
|
const dir = createTestDir();
|
||||||
|
const sessionFile = join(dir, "question-child.jsonl");
|
||||||
|
const running = {
|
||||||
|
id: "question-child",
|
||||||
|
name: "question-child",
|
||||||
|
task: "ask",
|
||||||
|
agent: "worker",
|
||||||
|
surface: "question-pane",
|
||||||
|
startTime: 0,
|
||||||
|
sessionFile,
|
||||||
|
interactive: false,
|
||||||
|
parentGeneration: shared.parentGeneration,
|
||||||
|
statusState: createStatusState({ source: "pi", startTimeMs: 0 }),
|
||||||
|
};
|
||||||
|
writeFileSync(`${sessionFile}.ask`, "{");
|
||||||
|
|
||||||
|
try {
|
||||||
|
testApi.deliverPendingQuestion(running);
|
||||||
|
assert.equal(existsSync(`${sessionFile}.ask`), true, "partial signals must be retried");
|
||||||
|
assert.equal(shared.pendingDeliveries.length, 0);
|
||||||
|
|
||||||
|
writeFileSync(`${sessionFile}.ask`, JSON.stringify({ question: "Still there?" }));
|
||||||
|
testApi.deliverPendingQuestion(running);
|
||||||
|
assert.equal(existsSync(`${sessionFile}.ask`), false);
|
||||||
|
assert.equal(shared.pendingDeliveries.length, 1);
|
||||||
|
shared.parentGeneration += 1;
|
||||||
|
const replacement = createMockExtensionApi();
|
||||||
|
shared.latestPi = replacement.api;
|
||||||
|
shared.activeRuntimeToken = Symbol.for("replacement-runtime");
|
||||||
|
shared.registeredRuntimeToken = shared.activeRuntimeToken;
|
||||||
|
testApi.flushPendingDeliveries();
|
||||||
|
assert.equal(replacement.sentMessages.length, 0);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates old children when replacement session_start wins the shutdown race", async () => {
|
||||||
|
const oldRuntime = createMockExtensionApi();
|
||||||
|
(subagentsModule as any).default(oldRuntime.api);
|
||||||
|
await emitMockEvent(oldRuntime, "session_start", { reason: "startup" }, ctx);
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
shared.runningSubagents.set("replaced-child", {
|
||||||
|
id: "replaced-child",
|
||||||
|
name: "replaced-child",
|
||||||
|
task: "old work",
|
||||||
|
surface: "old-pane",
|
||||||
|
startTime: 0,
|
||||||
|
sessionFile: "/tmp/replaced-child.jsonl",
|
||||||
|
interactive: false,
|
||||||
|
parentGeneration: shared.parentGeneration,
|
||||||
|
statusState: createStatusState({ source: "pi", startTimeMs: 0 }),
|
||||||
|
abortController,
|
||||||
|
});
|
||||||
|
testApi.sendWhenRuntimeReady(() => {
|
||||||
|
throw new Error("queue this old callback");
|
||||||
|
});
|
||||||
|
assert.equal(shared.pendingDeliveries.length, 1);
|
||||||
|
|
||||||
|
const freshRuntime = createMockExtensionApi();
|
||||||
|
(subagentsModule as any).default(freshRuntime.api);
|
||||||
|
await emitMockEvent(
|
||||||
|
freshRuntime,
|
||||||
|
"session_start",
|
||||||
|
{ reason: "new", previousSessionFile: "/tmp/old-parent.jsonl" },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(abortController.signal.aborted, true);
|
||||||
|
assert.equal(shared.runningSubagents.size, 0);
|
||||||
|
assert.equal(shared.pendingDeliveries.length, 0);
|
||||||
|
assert.equal(shared.parentGeneration, 1);
|
||||||
|
|
||||||
|
await emitMockEvent(oldRuntime, "session_shutdown", { reason: "new" }, ctx);
|
||||||
|
assert.equal(shared.parentGeneration, 1, "delayed old shutdown must not invalidate twice");
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves a live subagent and delivers its queued result through the fresh runtime", async () => {
|
it("preserves a live subagent and delivers its queued result through the fresh runtime", async () => {
|
||||||
const oldRuntime = createMockExtensionApi();
|
const oldRuntime = createMockExtensionApi();
|
||||||
(subagentsModule as any).default(oldRuntime.api);
|
(subagentsModule as any).default(oldRuntime.api);
|
||||||
@@ -1981,6 +2224,7 @@ describe("reload lifecycle", () => {
|
|||||||
sessionFile: string;
|
sessionFile: string;
|
||||||
statusState: ReturnType<typeof createStatusState>;
|
statusState: ReturnType<typeof createStatusState>;
|
||||||
interactive: boolean;
|
interactive: boolean;
|
||||||
|
parentGeneration: number;
|
||||||
abortController: AbortController;
|
abortController: AbortController;
|
||||||
suppressDelivery?: boolean;
|
suppressDelivery?: boolean;
|
||||||
} = {
|
} = {
|
||||||
@@ -1992,6 +2236,7 @@ describe("reload lifecycle", () => {
|
|||||||
sessionFile: "/tmp/reload-child.jsonl",
|
sessionFile: "/tmp/reload-child.jsonl",
|
||||||
statusState: createStatusState({ source: "pi", startTimeMs: Date.now() }),
|
statusState: createStatusState({ source: "pi", startTimeMs: Date.now() }),
|
||||||
interactive: false,
|
interactive: false,
|
||||||
|
parentGeneration: shared.parentGeneration,
|
||||||
abortController,
|
abortController,
|
||||||
};
|
};
|
||||||
shared.runningSubagents.set(running.id, running);
|
shared.runningSubagents.set(running.id, running);
|
||||||
@@ -2032,9 +2277,13 @@ describe("reload lifecycle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("tool registration", () => {
|
describe("tool registration", () => {
|
||||||
it("always resumes subagents as autonomous (auto-exit, non-interactive tracking)", () => {
|
it("always resumes subagents as autonomous with reliable follow-up delivery", () => {
|
||||||
const testApi = (subagentsModule as any).__test__;
|
const testApi = (subagentsModule as any).__test__;
|
||||||
|
|
||||||
|
assert.deepEqual(testApi.AUTOMATIC_DELIVERY_OPTIONS, {
|
||||||
|
triggerTurn: true,
|
||||||
|
deliverAs: "followUp",
|
||||||
|
});
|
||||||
assert.deepEqual(testApi.resolveResumeLaunchBehavior(), {
|
assert.deepEqual(testApi.resolveResumeLaunchBehavior(), {
|
||||||
autoExit: true,
|
autoExit: true,
|
||||||
interactive: false,
|
interactive: false,
|
||||||
@@ -2367,7 +2616,7 @@ describe("subagent interruption", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uniqueRunningName suffixes defaulted names that collide with running subagents", () => {
|
it("uniqueRunningName suffixes explicit or defaulted names that collide with running subagents", () => {
|
||||||
const testApi = (subagentsModule as any).__test__;
|
const testApi = (subagentsModule as any).__test__;
|
||||||
const runningMap = testApi.runningSubagents as Map<string, any>;
|
const runningMap = testApi.runningSubagents as Map<string, any>;
|
||||||
runningMap.clear();
|
runningMap.clear();
|
||||||
@@ -2432,6 +2681,61 @@ describe("subagent interruption", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serializes concurrent finished-session resumes by name", () => {
|
||||||
|
const testApi = (subagentsModule as any).__test__;
|
||||||
|
const resumingNames = testApi.resumingNames as Set<string>;
|
||||||
|
resumingNames.clear();
|
||||||
|
try {
|
||||||
|
assert.equal(testApi.reserveResumeName("Worker"), true);
|
||||||
|
assert.equal(testApi.reserveResumeName("Worker"), false);
|
||||||
|
assert.equal(testApi.reserveResumeName("Scout"), true);
|
||||||
|
} finally {
|
||||||
|
resumingNames.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears stale completion signals before resuming a finished session", () => {
|
||||||
|
const testApi = (subagentsModule as any).__test__;
|
||||||
|
const dir = createTestDir();
|
||||||
|
const sessionFile = join(dir, "finished.jsonl");
|
||||||
|
writeFileSync(`${sessionFile}.exit`, JSON.stringify({ type: "error" }));
|
||||||
|
writeFileSync(`${sessionFile}.ask`, JSON.stringify({ question: "stale" }));
|
||||||
|
try {
|
||||||
|
testApi.clearStaleSessionSignals(sessionFile);
|
||||||
|
assert.equal(existsSync(`${sessionFile}.exit`), false);
|
||||||
|
assert.equal(existsSync(`${sessionFile}.ask`), false);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes terminal completion and waits for the watcher before late steering", async () => {
|
||||||
|
const testApi = (subagentsModule as any).__test__;
|
||||||
|
const runningMap = testApi.runningSubagents as Map<string, any>;
|
||||||
|
const dir = createTestDir();
|
||||||
|
const completionFile = join(dir, "child.exit-code");
|
||||||
|
const running: any = makeRunning({ sessionFile: join(dir, "child.jsonl"), completionFile });
|
||||||
|
runningMap.set(running.id, running);
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.equal(testApi.hasTerminalCompletionSignal(running), false);
|
||||||
|
writeFileSync(completionFile, "0\n");
|
||||||
|
assert.equal(testApi.hasTerminalCompletionSignal(running), true);
|
||||||
|
rmSync(completionFile);
|
||||||
|
running.completionObserved = true;
|
||||||
|
assert.equal(
|
||||||
|
testApi.hasTerminalCompletionSignal(running),
|
||||||
|
true,
|
||||||
|
"watcher state must preserve completion after consuming the file",
|
||||||
|
);
|
||||||
|
setTimeout(() => runningMap.delete(running.id), 10);
|
||||||
|
assert.equal(await testApi.waitForWatcherCleanup(running, new AbortController().signal, 200), true);
|
||||||
|
} finally {
|
||||||
|
runningMap.clear();
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("steers a running subagent by typing into its pane (newlines flattened)", () => {
|
it("steers a running subagent by typing into its pane (newlines flattened)", () => {
|
||||||
const testApi = (subagentsModule as any).__test__;
|
const testApi = (subagentsModule as any).__test__;
|
||||||
let sentSurface = "";
|
let sentSurface = "";
|
||||||
|
|||||||
Reference in New Issue
Block a user