pi-extended-teams 2.1.18 → 2.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +25 -12
  3. package/SECURITY.md +19 -0
  4. package/docs/access.md +35 -0
  5. package/extensions/agents/read-agent.ts +157 -5
  6. package/extensions/agents/write-agent.ts +8 -5
  7. package/extensions/events/register-events.ts +1 -1
  8. package/extensions/index.ts +50 -5
  9. package/extensions/runtime/nested-read-agents.ts +1 -1
  10. package/extensions/runtime/teammate-interrupt.ts +222 -0
  11. package/extensions/runtime/types.ts +15 -0
  12. package/extensions/team/team-contracts.json +1 -1
  13. package/extensions/tools/agent-status-tool.ts +270 -0
  14. package/extensions/tools/task-runtime-tools.ts +24 -3
  15. package/extensions/tools/team-tools.ts +76 -16
  16. package/extensions/ui/agent-follow-view.ts +32 -6
  17. package/extensions/ui/agent-navigation.ts +2 -0
  18. package/extensions/ui/renderers.ts +4 -4
  19. package/extensions/ui/theme.ts +1 -1
  20. package/package.json +15 -10
  21. package/skills/teams.md +14 -11
  22. package/src/adapters/tmux-adapter.ts +11 -0
  23. package/src/orchestration/index.ts +1 -1
  24. package/src/utils/runtime.ts +4 -0
  25. package/src/utils/settings.ts +23 -1
  26. package/src/utils/terminal-adapter.ts +7 -0
  27. package/assets/pi-extended-teams-agent-navigation.png +0 -0
  28. package/assets/pi-extended-teams-in-action.png +0 -0
  29. package/extensions/agents/read-agent-session-lifecycle.test.ts +0 -605
  30. package/extensions/agents/read-agent.test.ts +0 -3077
  31. package/extensions/agents/write-agent.test.ts +0 -513
  32. package/extensions/events/register-events.test.ts +0 -465
  33. package/extensions/index.test.ts +0 -1659
  34. package/extensions/internal/agent-session-files.test.ts +0 -164
  35. package/extensions/internal/pi-command.test.ts +0 -191
  36. package/extensions/internal/pi-runtime-api.test.ts +0 -43
  37. package/extensions/internal/session-context-reference.test.ts +0 -290
  38. package/extensions/internal/session-files.test.ts +0 -225
  39. package/extensions/resources/spawn-resource-plan.test.ts +0 -233
  40. package/extensions/runtime/active-agent-sleep.test.ts +0 -157
  41. package/extensions/runtime/pending-child-controller.test.ts +0 -169
  42. package/extensions/team/contracts.test.ts +0 -111
  43. package/extensions/team/lifecycle.test.ts +0 -1095
  44. package/extensions/team/recipient-closure.test.ts +0 -97
  45. package/extensions/team/roster.test.ts +0 -129
  46. package/extensions/team/writer-screens.test.ts +0 -50
  47. package/extensions/tools/agent-communication-tools.test.ts +0 -306
  48. package/extensions/tools/coordination-tools.test.ts +0 -670
  49. package/extensions/tools/delegation-guard.test.ts +0 -95
  50. package/extensions/tools/file-claim-tools.test.ts +0 -104
  51. package/extensions/tools/predefined-tools.test.ts +0 -647
  52. package/extensions/tools/read-helper.test.ts +0 -35
  53. package/extensions/tools/task-runtime-tools.test.ts +0 -334
  54. package/extensions/tools/team-tools.read-agent.test.ts +0 -1924
  55. package/extensions/ui/agent-follow-view.test.ts +0 -578
  56. package/extensions/ui/agent-navigation.test.ts +0 -52
  57. package/extensions/ui/extensions-command.test.ts +0 -336
  58. package/extensions/ui/favorite-models-command.test.ts +0 -280
  59. package/extensions/ui/read-agent-status.test.ts +0 -117
  60. package/extensions/ui/status-widget.test.ts +0 -169
  61. package/extensions/ui-frame.test.ts +0 -69
  62. package/src/adapters/tmux-adapter.test.ts +0 -276
  63. package/src/orchestration/orchestrator.test.ts +0 -396
  64. package/src/utils/claims.test.ts +0 -137
  65. package/src/utils/hooks.test.ts +0 -75
  66. package/src/utils/lifecycle-tombstone.test.ts +0 -105
  67. package/src/utils/lock.race.child.ts +0 -44
  68. package/src/utils/lock.race.test.ts +0 -198
  69. package/src/utils/lock.test.ts +0 -90
  70. package/src/utils/messaging.test.ts +0 -337
  71. package/src/utils/model-resolution.test.ts +0 -231
  72. package/src/utils/models.test.ts +0 -8
  73. package/src/utils/predefined-teams.save-template.test.ts +0 -74
  74. package/src/utils/predefined-teams.test.ts +0 -441
  75. package/src/utils/report-events.test.ts +0 -154
  76. package/src/utils/runtime.test.ts +0 -314
  77. package/src/utils/security.test.ts +0 -43
  78. package/src/utils/settings.test.ts +0 -480
  79. package/src/utils/shared-memory.test.ts +0 -80
  80. package/src/utils/tasks.race.test.ts +0 -44
  81. package/src/utils/tasks.test.ts +0 -229
  82. package/src/utils/thinking-levels.test.ts +0 -56
  83. package/src/utils/workflow-metadata.test.ts +0 -11
  84. package/src/utils/write-queue.test.ts +0 -251
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leonardo Pereira
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -6,7 +6,15 @@ pi-extended-teams lets the lead split work into bounded lanes, watch agents live
6
6
 
7
7
  Read agents are the default. Edit agents are opt-in and should own isolated files.
8
8
 
9
- [![Three agents working in parallel inside Pi](assets/pi-extended-teams-in-action.png)](assets/pi-extended-teams-in-action.png)
9
+ [![Three agents working in parallel inside Pi](https://raw.githubusercontent.com/dantetekanem/pi-extended-teams/main/assets/pi-extended-teams-in-action.png)](https://raw.githubusercontent.com/dantetekanem/pi-extended-teams/main/assets/pi-extended-teams-in-action.png)
10
+
11
+ ## Try without installing
12
+
13
+ ```bash
14
+ pi -e npm:pi-extended-teams
15
+ ```
16
+
17
+ This runs the published package for the current Pi invocation without adding it to your project configuration.
10
18
 
11
19
  ## Install and run
12
20
 
@@ -22,23 +30,22 @@ Or install directly from GitHub:
22
30
  pi install git:github.com/dantetekanem/pi-extended-teams
23
31
  ```
24
32
 
25
- Run `/agents-favorite-models` once in Pi and choose a model and thinking level for each intent tier.
26
-
27
33
  Then ask for help naturally:
28
34
 
29
35
  ```text
30
36
  Review the current changes with separate agents for correctness, test gaps, and security. Give me the evidence so I can make the final call.
31
37
  ```
32
38
 
33
- The current Pi session becomes the agent group automatically. There is no separate setup step.
39
+ The current Pi session becomes the agent group automatically. There is no separate setup step. Until you configure a tier, it uses the current lead-session model and thinking level. Run `/agents-favorite-models` when you want particular tiers to use different models.
34
40
 
35
41
  ## How it works
36
42
 
37
- - Read and edit agents run in-process and stay connected to the current Pi session.
43
+ - Read agents run in-process. Edit agents run in separate Pi sessions through the configured terminal adapter. Both stay connected to the lead session.
38
44
  - The activity card shows progress, intent tier, elapsed time, tokens, and tool activity.
39
- - You can open an agent's transcript, send it a message, or stop it.
45
+ - You can open an agent's transcript, send it a message, interrupt a stuck tool command, or stop it.
40
46
  - Completed reports return to the lead automatically and remain recoverable when needed.
41
- - Every spawn uses a configured intent tier instead of ad hoc model settings.
47
+ - `get_agent_status` gives the lead or an eligible nested parent one read-only snapshot of owned active, queued, stalled, or recently completed read and edit agents.
48
+ - Every spawn names an intent tier instead of choosing ad hoc model settings. Configured favorites take priority; unset tiers inherit the current lead model and thinking.
42
49
  - Edit agents can claim isolated files. Claims coordinate cooperative agents; they are not access control.
43
50
  - Lazy session context and nested read helpers are available when a bounded task needs them.
44
51
 
@@ -46,17 +53,19 @@ This works well for multi-angle code review, root-cause investigation, parallel
46
53
 
47
54
  ## Live control
48
55
 
49
- With the editor empty, press Down to open agent navigation. Use Down/Up to move, `l` to expand large tool logs, `m` to message an agent, `x` to stop it, and Escape to return.
56
+ With the editor empty, press Down to open agent navigation. Use Down/Up to move, `l` to expand large tool logs, `m` to message an agent, `i` to interrupt its currently running tool command, `x` to stop the whole agent, and Escape to return.
57
+
58
+ The lead can invoke the same command-only behavior with `interrupt_teammate({ agent_name: "agent" })`. It keeps the agent's session, task context, and file claims intact so you can send follow-up work. In-process cancellation is cooperative and may report that it is still pending; for tmux-backed agents, success means Pi's Escape key was delivered, not that command settlement was independently confirmed.
50
59
 
51
- [![Inspecting and messaging a running agent](assets/pi-extended-teams-agent-navigation.png)](assets/pi-extended-teams-agent-navigation.png)
60
+ [![Inspecting and messaging a running agent](https://raw.githubusercontent.com/dantetekanem/pi-extended-teams/main/assets/pi-extended-teams-agent-navigation.png)](https://raw.githubusercontent.com/dantetekanem/pi-extended-teams/main/assets/pi-extended-teams-agent-navigation.png)
52
61
 
53
- Completed reports wake the lead automatically. Do not poll with sleeps, loops, or repeated status checks. Use `check_teammate` only when one agent appears unhealthy or a persisted report needs recovery.
62
+ Completed reports wake the lead automatically. End the current turn to wait. One `get_agent_status` snapshot is allowed when current status is needed; do not poll with sleeps, loops, or repeated checks. Use `check_teammate` only when `get_agent_status` shows a suspected lifecycle failure or a persisted report needs recovery.
54
63
 
55
64
  The lead owns decomposition, integration, and acceptance. Pi packages and spawned agents run with your system permissions, so review project-local instructions and configuration through Pi's normal trust flow.
56
65
 
57
66
  ## Intent tiers
58
67
 
59
- Every spawn uses a configured `model_slot`:
68
+ Every spawn names a `model_slot`. Configured favorites take priority; otherwise the tier uses the current lead-session model and thinking level:
60
69
 
61
70
  | Tier | Use it for |
62
71
  | --- | --- |
@@ -89,10 +98,14 @@ For an edit, choose a write tier and name the files it may claim. Never run over
89
98
 
90
99
  ## Configuration
91
100
 
92
- Global settings live at `~/.pi/agent/pi-extended-teams/settings.json`. Project overrides live at `.pi/pi-extended-teams.json`. Favorite intent tiers are global so `/agents-favorite-models` and spawning use the same choices.
101
+ Global settings live at `~/.pi/agent/pi-extended-teams/settings.json`. Project overrides live at `.pi/pi-extended-teams.json`. Favorite intent tiers are global so `/agents-favorite-models` and spawning use the same choices. Configuring favorites is optional; an unset tier falls back to the current lead-session model and thinking level.
93
102
 
94
103
  Spawned sessions are private by default under `~/.pi/teams/<team>/agent-sessions/` and stay out of Pi's normal `/resume` picker.
95
104
 
105
+ ## Security and data access
106
+
107
+ Read [SECURITY.md](SECURITY.md) for private vulnerability reporting and [docs/access.md](docs/access.md) for the subprocess, filesystem, extension, hook, and network boundaries.
108
+
96
109
  ## Development
97
110
 
98
111
  ```bash
package/SECURITY.md ADDED
@@ -0,0 +1,19 @@
1
+ # Security policy
2
+
3
+ ## Supported versions
4
+
5
+ Security fixes are made on the latest published version of `pi-extended-teams`.
6
+
7
+ ## Reporting a vulnerability
8
+
9
+ Do not open a public issue for a suspected vulnerability. Use [GitHub private vulnerability reporting](https://github.com/dantetekanem/pi-extended-teams/security/advisories/new) for this repository. Include a description, affected versions, reproduction steps, and impact.
10
+
11
+ If private reporting is unavailable, contact the maintainer through the repository owner's GitHub profile. Do not include credentials, private prompts, or other sensitive data in a public issue.
12
+
13
+ Reports are acknowledged after maintainer review. Disclosure timing and any fix will be coordinated with the reporter.
14
+
15
+ ## Trust boundary
16
+
17
+ This package coordinates local Pi sessions. A spawned teammate receives the prompt, project working directory, configured model selection, and the capabilities of its child Pi process. Review the access disclosure before using it with a project or credentials you do not intend a teammate to access.
18
+
19
+ See [docs/access.md](docs/access.md) for the process, file, extension, hook, and network boundaries.
package/docs/access.md ADDED
@@ -0,0 +1,35 @@
1
+ # Access and execution disclosure
2
+
3
+ `pi-extended-teams` is a local orchestration extension. It creates teammates that act in the working directory selected for the teammate. A teammate is not a sandbox or a security boundary.
4
+
5
+ ## Child processes
6
+
7
+ - Read teammates run in the lead Pi process through Pi's `AgentSession` API. They do not receive a tmux pane.
8
+ - Write teammates start a separate Pi process in a tmux pane. The extension builds and runs the local Pi launch command with the teammate's working directory, model and thinking configuration, selected extensions, and `PI_TEAM_NAME`, `PI_AGENT_NAME`, and `PI_LIFECYCLE_RUN_ID` environment variables.
9
+ - Before launching a write teammate, the extension runs a bounded local `pi models --all` preflight through `sh -c` to determine whether the configured model is available.
10
+ - On macOS, while at least one write teammate is active, the extension may run `/usr/bin/caffeinate -i -w <lead-pid>` to prevent idle system sleep. It terminates that helper when no write teammates remain or the extension is disposed.
11
+ - In a Herdr session, the extension can invoke the configured `herdr` binary to split, start, and close panes when moving an agent. The binary path defaults to `herdr` and can be set with `HERDR_BIN_PATH`.
12
+
13
+ ## Files and local state
14
+
15
+ The extension reads settings from `~/.pi/agent/pi-extended-teams/settings.json` and `<project>/.pi/pi-extended-teams.json`. Model-provider compatibility also reads the legacy `~/.pi/pi-extended-teams.json` path. Predefined agents can be read from `~/.pi/agent/agents/` and `<project>/.pi/agents/`; team templates can be read or written at `~/.pi/teams.yaml`, `~/.pi/agent/teams.yaml`, and `<project>/.pi/teams.yaml`.
16
+
17
+ For a team named `<team>`, coordination state is stored under `~/.pi/teams/<team>/`. This includes `config.json`, inboxes, runtime status, session-context references, lifecycle quarantine and tombstones, file claims, write and read-helper queues, shared memory, report events, lead-session metadata, debug logs, and private child transcripts under `agent-sessions/`. Task records are stored under `~/.pi/tasks/<team>/`, and agent reports may also be written under `~/.pi/agent/reports/`. Cleanup removes lifecycle and queue files when they are no longer needed.
18
+
19
+ The extension reads project extension sources only when Pi marks the same working directory trusted. Teammates receive the working directory supplied at spawn time. A write teammate can use any filesystem access granted to its child Pi process and enabled tools. A read teammate's tools are restricted by the extension, but it still receives prompt and project context supplied by the lead.
20
+
21
+ ## Extensions and hooks
22
+
23
+ A child Pi process starts with `--no-extensions`, then receives `pi-extended-teams` plus only lead-session extensions selected by the extension policy. The package does not automatically grant every available extension to a child. Project trust is forwarded only when the child uses the same working directory as the trusted lead session. Enabled extensions can add their own tools, hooks, file access, subprocesses, or network behavior; review them separately.
24
+
25
+ The package's documented project hook is `.pi/team-hooks/task_completed.sh`, which runs when a teammate marks a task complete. Hooks are project-supplied executable code and run with the permissions of the local Pi process. Do not enable or keep hooks you do not trust.
26
+
27
+ ## Network and model providers
28
+
29
+ The extension does not make application network requests itself. Pi and any enabled extensions may make network requests. In particular, a child Pi process can send its prompt and tool context to the configured model provider, subject to Pi's provider configuration. The model provider, enabled extensions, and tools determine any additional network access.
30
+
31
+ Use a model provider and extension set appropriate for the project data. Do not delegate secrets or data that you are not authorized to share with the configured provider or local tools.
32
+
33
+ ## Operator controls
34
+
35
+ The lead controls teammate creation, messages, task assignment, and stop/interrupt actions. A teammate cannot access lead-only process-control tools. File claims coordinate edits but do not enforce operating-system access control.
@@ -10,6 +10,7 @@ import { createAgentCommunicationTools, type SubmittedAgentReport } from "../too
10
10
  import { requireWriteAgentTeam } from "../team/roster";
11
11
  import { isPiPromptPlanningMember, shouldSuppressLeadReportInjection } from "../../src/utils/workflow-metadata";
12
12
  import { canonicalPersistedModelSlot, loadSettings, requireFavoriteModelLevel } from "../../src/utils/settings";
13
+ import { parseQualifiedModel } from "../../src/utils/model-resolution";
13
14
  import { closePersistedRecipient } from "../team/recipient-closure";
14
15
  import { generateExtensionInstanceId, generateLifecycleRunId } from "../../src/utils/lifecycle-tombstone";
15
16
  import { createLifecycleRuntime, type ShutdownTeammateOptions } from "../team/lifecycle";
@@ -45,6 +46,45 @@ export { closeReadAgentMessageDelivery } from "./read-agent-session-lifecycle";
45
46
 
46
47
  const pendingParentWakeSignals = new WeakMap<RunningReadAgent, () => void>();
47
48
 
49
+ interface ReadAgentWakeState {
50
+ generation: number;
51
+ waiters: Set<() => void>;
52
+ }
53
+
54
+ const readAgentWakeStates = new WeakMap<RunningReadAgent, ReadAgentWakeState>();
55
+
56
+ function readAgentWakeState(agent: RunningReadAgent): ReadAgentWakeState {
57
+ let state = readAgentWakeStates.get(agent);
58
+ if (!state) {
59
+ state = { generation: 0, waiters: new Set() };
60
+ readAgentWakeStates.set(agent, state);
61
+ }
62
+ return state;
63
+ }
64
+
65
+ function readAgentWakeGeneration(agent: RunningReadAgent): number {
66
+ return readAgentWakeState(agent).generation;
67
+ }
68
+
69
+ function signalReadAgentWake(agent: RunningReadAgent): void {
70
+ const state = readAgentWakeState(agent);
71
+ state.generation += 1;
72
+ const waiters = Array.from(state.waiters);
73
+ state.waiters.clear();
74
+ for (const resolve of waiters) resolve();
75
+ }
76
+
77
+ function waitForReadAgentWake(agent: RunningReadAgent, generation: number): Promise<void> {
78
+ const state = readAgentWakeState(agent);
79
+ if (state.generation !== generation) return Promise.resolve();
80
+ return new Promise<void>((resolve) => { state.waiters.add(resolve); });
81
+ }
82
+
83
+ function disposeReadAgentWake(agent: RunningReadAgent): void {
84
+ signalReadAgentWake(agent);
85
+ readAgentWakeStates.delete(agent);
86
+ }
87
+
48
88
  export interface RunReadAgentOptions {
49
89
  isTeammate: boolean;
50
90
  getTeamName(): string | null | undefined;
@@ -95,6 +135,52 @@ function markReadAgentActivity(
95
135
  pushReadAgentEvent(agent, text);
96
136
  }
97
137
 
138
+ interface ReadAgentOperationOutcome {
139
+ generation: number;
140
+ interrupted: boolean;
141
+ }
142
+
143
+ async function runReadAgentSessionOperation(
144
+ state: RunningReadAgent,
145
+ operation: () => Promise<void>,
146
+ ): Promise<ReadAgentOperationOutcome> {
147
+ const generation = (state.operationGeneration ?? 0) + 1;
148
+ state.operationGeneration = generation;
149
+ state.activeOperationGeneration = generation;
150
+ state.completedOperationError = undefined;
151
+ let settleOperation!: () => void;
152
+ const operationSettlement = new Promise<void>((resolve) => { settleOperation = resolve; });
153
+ state.activeOperationSettlementPromise = operationSettlement;
154
+
155
+ try {
156
+ let failure: unknown;
157
+ try {
158
+ await operation();
159
+ } catch (error) {
160
+ failure = error;
161
+ }
162
+
163
+ if (state.activeOperationGeneration === generation) state.activeOperationGeneration = undefined;
164
+ const interrupted = state.interruptRequestedGeneration === generation;
165
+ if (interrupted) state.interruptRequestedGeneration = undefined;
166
+ state.completedOperationGeneration = generation;
167
+ state.completedOperationInterrupted = interrupted;
168
+ state.completedOperationError = interrupted ? undefined : failure;
169
+
170
+ if (interrupted) {
171
+ markReadAgentActivity(state, "command interrupted; waiting for lead", "thinking");
172
+ return { generation, interrupted: true };
173
+ }
174
+ if (failure) throw failure;
175
+ return { generation, interrupted: false };
176
+ } finally {
177
+ if (state.activeOperationSettlementPromise === operationSettlement) {
178
+ state.activeOperationSettlementPromise = undefined;
179
+ }
180
+ settleOperation();
181
+ }
182
+ }
183
+
98
184
  function refreshReadAgentStats(agent: RunningReadAgent, session: AgentSession, activityAlreadyMarked = false): void {
99
185
  const stats = session.getSessionStats();
100
186
  const tokensUsed = stats.tokens.total;
@@ -265,12 +351,20 @@ export async function sendMessageToRunningReadAgent(agent: RunningReadAgent | un
265
351
  }
266
352
 
267
353
  const session = agent.session;
268
- const delivery = session.isStreaming ? { deliverAs: "steer" as const } : undefined;
269
354
  const deliveryResult = enqueueReadAgentMessageDelivery(
270
355
  agent,
271
356
  agent.name,
272
- () => session.sendUserMessage(content, delivery)
357
+ async () => {
358
+ const pendingInterrupt = agent.operationInterruptPromise;
359
+ if (pendingInterrupt) await pendingInterrupt.catch(() => {});
360
+ if (session.isStreaming) {
361
+ await session.sendUserMessage(content, { deliverAs: "steer" as const });
362
+ return;
363
+ }
364
+ await runReadAgentSessionOperation(agent, () => session.sendUserMessage(content, undefined));
365
+ }
273
366
  );
367
+ signalReadAgentWake(agent);
274
368
  pendingParentWakeSignals.get(agent)?.();
275
369
  await deliveryResult;
276
370
  markReadAgentActivity(agent, "received lead message", "thinking");
@@ -350,6 +444,7 @@ async function recordReadAgentReportEvent(
350
444
  metadata: {
351
445
  ...(member.prompt ? { initialPrompt: member.prompt } : {}),
352
446
  ...(modelSlot ? { modelSlot } : {}),
447
+ ...(member.parentLifecycleRunId ? { parentLifecycleRunId: member.parentLifecycleRunId } : {}),
353
448
  ...reportMetadata,
354
449
  },
355
450
  });
@@ -690,7 +785,9 @@ export async function runReadAgentInProcess(
690
785
  if (pendingChildParent && !pendingChildController) {
691
786
  throw new Error(`Eligible nested read parent ${member.name} requires a pending child controller.`);
692
787
  }
693
- const [provider, modelId] = (member.model || "").split("/", 2);
788
+ const parsedModel = parseQualifiedModel(member.model || "");
789
+ const provider = parsedModel?.provider;
790
+ const modelId = parsedModel?.model;
694
791
  const model = provider && modelId ? ctx.modelRegistry.find(provider, modelId) : undefined;
695
792
  if (!model) {
696
793
  throw new Error(`Read agent model "${member.model}" is not available.`);
@@ -769,6 +866,7 @@ export async function runReadAgentInProcess(
769
866
  ? [
770
867
  "This opted-in depth-0 write-feature/write-critical run may use restricted spawn_agent or spawn_swarm_agents for depth-1 read-only helpers. Choose any canonical read-* tier and any helper count, subject to the team's global read capacity and queue. Children report to you and cannot delegate.",
771
868
  "Use spawn_agent with only name (optional), prompt, and model_slot. Use spawn_swarm_agents with optional defaults.model_slot plus agents using only name (optional), prompt, and model_slot.",
869
+ "When waiting on children, end your turn without calling report_and_exit; each child report resumes you automatically. One get_agent_status snapshot is allowed when current status is needed, but never call it repeatedly.",
772
870
  "Do not request write tiers, cwd/team overrides, metadata, replacement of an active/queued name, or delegation outside your assigned scope.",
773
871
  ]
774
872
  : ["You cannot spawn or create other agents. If another agent is needed, use send_message to ask team-lead; only the lead decides and performs the spawn."]),
@@ -908,10 +1006,51 @@ export async function runReadAgentInProcess(
908
1006
  );
909
1007
  };
910
1008
 
1009
+ const waitForPostInterruptOperation = async (
1010
+ interruptedGeneration: number,
1011
+ initialWakeGeneration: number,
1012
+ ): Promise<boolean> => {
1013
+ state.operationAwaitingResume = true;
1014
+ let observedOperationGeneration = interruptedGeneration;
1015
+ let observedWakeGeneration = initialWakeGeneration;
1016
+ try {
1017
+ while (!state.stopRequested && options.isCurrentReadAgentRun(key, state)) {
1018
+ const currentWakeGeneration = readAgentWakeGeneration(state);
1019
+ if (currentWakeGeneration === observedWakeGeneration) {
1020
+ const wake = waitForReadAgentWake(state, observedWakeGeneration);
1021
+ await (state.finished ? Promise.race([wake, state.finished]) : wake);
1022
+ }
1023
+ observedWakeGeneration = readAgentWakeGeneration(state);
1024
+ if (state.stopRequested || !options.isCurrentReadAgentRun(key, state)) return false;
1025
+
1026
+ const deliveryTail = state.messageDeliveryTail;
1027
+ if (deliveryTail) await deliveryTail.catch(() => {});
1028
+ const completedGeneration = state.completedOperationGeneration ?? 0;
1029
+ if (completedGeneration <= observedOperationGeneration) continue;
1030
+ observedOperationGeneration = completedGeneration;
1031
+ if (state.completedOperationError) throw state.completedOperationError;
1032
+ if (state.completedOperationInterrupted) continue;
1033
+ return true;
1034
+ }
1035
+ return false;
1036
+ } finally {
1037
+ state.operationAwaitingResume = false;
1038
+ }
1039
+ };
1040
+
911
1041
  state.acceptingMessages = true;
912
1042
  try {
913
- await session.prompt(prompt, { source: "extension" as any });
1043
+ const initialWakeGeneration = readAgentWakeGeneration(state);
1044
+ const initialOperation = await runReadAgentSessionOperation(
1045
+ state,
1046
+ () => session.prompt(prompt, { source: "extension" as any }),
1047
+ );
914
1048
  refreshReadAgentStats(state, session);
1049
+ if (initialOperation.interrupted) {
1050
+ const resumed = await waitForPostInterruptOperation(initialOperation.generation, initialWakeGeneration);
1051
+ if (!resumed) return;
1052
+ refreshReadAgentStats(state, session);
1053
+ }
915
1054
  if (pendingChildController && pendingChildParent) {
916
1055
  while (!submittedFinalReport && !state.stopRequested) {
917
1056
  const snapshot = pendingChildController.observeParent(pendingChildParent);
@@ -945,8 +1084,20 @@ export async function runReadAgentInProcess(
945
1084
  markReadAgentActivity(state, "recovering missing report", "thinking");
946
1085
  options.renderReadAgentStatus();
947
1086
  try {
948
- await session.prompt(EMPTY_REPORT_RECOVERY_PROMPT, { source: "extension" as any });
1087
+ const recoveryWakeGeneration = readAgentWakeGeneration(state);
1088
+ const recoveryOperation = await runReadAgentSessionOperation(
1089
+ state,
1090
+ () => session.prompt(EMPTY_REPORT_RECOVERY_PROMPT, { source: "extension" as any }),
1091
+ );
949
1092
  refreshReadAgentStats(state, session);
1093
+ if (recoveryOperation.interrupted) {
1094
+ const resumed = await waitForPostInterruptOperation(
1095
+ recoveryOperation.generation,
1096
+ recoveryWakeGeneration,
1097
+ );
1098
+ if (!resumed) return;
1099
+ refreshReadAgentStats(state, session);
1100
+ }
950
1101
  } catch (error) {
951
1102
  throw unavailableReportError(
952
1103
  `The report-only recovery turn failed: ${error instanceof Error ? error.message : String(error)}`,
@@ -1105,6 +1256,7 @@ export async function runReadAgentInProcess(
1105
1256
  }
1106
1257
  } finally {
1107
1258
  pendingParentWakeSignals.delete(state);
1259
+ disposeReadAgentWake(state);
1108
1260
  settleSessionCreation(state.session);
1109
1261
  // End run-owned activity even when persisted lifecycle cleanup is refused.
1110
1262
  if (state.heartbeatTimer) clearInterval(state.heartbeatTimer);
@@ -3,7 +3,7 @@ import * as teams from "../../src/utils/teams";
3
3
  import * as messaging from "../../src/utils/messaging";
4
4
  import * as runtime from "../../src/utils/runtime";
5
5
  import * as writeQueue from "../../src/utils/write-queue";
6
- import { loadSettings, requireFavoriteModelLevel } from "../../src/utils/settings";
6
+ import { loadSettings, normalizeFavoriteModelSlot, requireConfiguredFavoriteModel, roleForFavoriteModelSlot } from "../../src/utils/settings";
7
7
  import type { Member } from "../../src/utils/models";
8
8
  import { isTeamsDebugEnabled, teamDebugLogPath, writeTeamsDebugEvent } from "../internal/debug";
9
9
  import { buildPiCommand, checkChildPiModelAvailability, getPiExtendedTeamsExtensionSource, getPiLaunchCommand } from "../internal/pi-command";
@@ -60,12 +60,15 @@ function stopAndVerifySpawnedPane(terminal: any, paneId: string): string | null
60
60
 
61
61
  function assertWriterUsesConfiguredLevel(member: Member): void {
62
62
  const settings = loadSettings({ projectDir: member.cwd });
63
- const level = requireFavoriteModelLevel(settings, member.modelSlot);
64
- if (level.role !== "write" || member.role !== "write") {
63
+ const configured = requireConfiguredFavoriteModel(settings, member.modelSlot);
64
+ const slot = configured?.slot ?? normalizeFavoriteModelSlot(member.modelSlot);
65
+ if (!slot) throw new Error(`Unknown favorite model slot "${String(member.modelSlot)}".`);
66
+ const role = roleForFavoriteModelSlot(slot);
67
+ if (role !== "write" || member.role !== "write") {
65
68
  throw new Error(`Write agent ${member.name} must use a write-* intent tier configured via /agents-favorite-models. Spawn agents by intent tier only.`);
66
69
  }
67
- if (member.model !== level.model || member.thinking !== level.thinking) {
68
- throw new Error(`Write agent ${member.name} must use configured intent tier ${level.slot}; direct model/thinking overrides are not allowed.`);
70
+ if (configured && (member.model !== configured.config.model || member.thinking !== configured.config.thinking)) {
71
+ throw new Error(`Write agent ${member.name} must use configured intent tier ${configured.slot}; direct model/thinking overrides are not allowed.`);
69
72
  }
70
73
  }
71
74
 
@@ -14,7 +14,7 @@ import { generateLifecycleRunId } from "../../src/utils/lifecycle-tombstone";
14
14
  import { cleanupStaleSessionContextReferences } from "../internal/session-context-reference";
15
15
  import { cleanupStalePrivateAgentSessions } from "../internal/agent-session-files";
16
16
 
17
- export const LEAD_ORCHESTRATION_GUIDANCE = `\n\npi-extended-teams lead orchestration rules:\n- Choose tiers by the agent's intended outcome, not by vague task importance. read-review is the normal default for focused review, verification, and bounded synthesis.\n- Use read-collect when the lane gathers bounded facts without owning the conclusion. Use read-analyze when it must explain behavior or root cause across connected evidence. Reserve read-critical for irreducible high-stakes security, architecture, concurrency, migration, or data-correctness reasoning.\n- For edits, use write-patch for a narrow localized change, write-feature for a bounded feature with a known design, write-system for a cross-cutting integration/refactor within explicitly claimed files, and write-critical only for high-risk security, concurrency, recovery, migration, or data-integrity changes.\n- Prefer the canonical read-*/write-* tiers. Legacy reading-*/writing-* names are compatibility aliases for this minor release, not intent guidance.\n- A spawned agent owns its assigned lane until it reports, blocks, fails, or the user cancels it. Do not duplicate, take over, test, edit, or synthesize that same lane in parallel; work only on clearly unrelated lanes.\n- When no unrelated work remains, wait literally idle for the automatic report prompt. Do not sleep, poll, repeatedly call read_inbox/check status, send nudges, do dummy work, or treat healthy silence as failure.\n- Wait for the actual report before synthesizing. Intervene only on a reported blocker/error, actual health failure, explicit user cancellation/change, or a genuinely finished agent that remains active.\n- For durable bug, security, or testing claims from an agent report or backlog, concrete, reproducible findings with file/line evidence or a focused failing regression may proceed directly to TDD repair.
17
+ export const LEAD_ORCHESTRATION_GUIDANCE = `\n\npi-extended-teams lead orchestration rules:\n- Choose tiers by the agent's intended outcome, not by vague task importance. read-review is the normal default for focused review, verification, and bounded synthesis.\n- Use read-collect when the lane gathers bounded facts without owning the conclusion. Use read-analyze when it must explain behavior or root cause across connected evidence. Reserve read-critical for irreducible high-stakes security, architecture, concurrency, migration, or data-correctness reasoning.\n- For edits, use write-patch for a narrow localized change, write-feature for a bounded feature with a known design, write-system for a cross-cutting integration/refactor within explicitly claimed files, and write-critical only for high-risk security, concurrency, recovery, migration, or data-integrity changes.\n- Prefer the canonical read-*/write-* tiers. Legacy reading-*/writing-* names are compatibility aliases for this minor release, not intent guidance.\n- A spawned agent owns its assigned lane until it reports, blocks, fails, or the user cancels it. Do not duplicate, take over, test, edit, or synthesize that same lane in parallel; work only on clearly unrelated lanes.\n- When no unrelated work remains, end the turn. The extension resumes you when a report arrives. One get_agent_status snapshot is allowed when current status is needed; do not repeatedly call it, sleep, busy-wait, loop on inbox/status, send nudges, do dummy work, or treat healthy silence as failure.\n- Wait for the actual report before synthesizing. Intervene only on a reported blocker/error, actual health failure, or explicit user cancellation/change.\n- For durable bug, security, or testing claims from an agent report or backlog, concrete, reproducible findings with file/line evidence or a focused failing regression may proceed directly to TDD repair.
18
18
  - Use a separate read-only confirmation only when evidence is missing or weak, the claim is disputed, or irreducible high-risk uncertainty remains; never reconfirm an already confirmed finding.`;
19
19
 
20
20
  export interface RegisterEventsOptions {
@@ -18,10 +18,12 @@ import { createReportProgressTool } from "./tools/agent-communication-tools.js";
18
18
  import { registerTaskRuntimeTools } from "./tools/task-runtime-tools.js";
19
19
  import { registerTeamTools, type TeamToolsRuntime } from "./tools/team-tools.js";
20
20
  import { canonicalPersistedModelSlot, loadSettings, requireFavoriteModelLevel } from "../src/utils/settings";
21
+ import { parseQualifiedModel } from "../src/utils/model-resolution";
21
22
  import { formatAnimatedProgress, formatContextUsage, formatElapsed, formatModelLabel } from "./ui/renderers.js";
22
23
  import type { CompletedAgentReport, RunningReadAgent } from "./runtime/types.js";
23
24
  import { createPendingChildController } from "./runtime/pending-child-controller.js";
24
25
  import { createActiveAgentSleepController, runWithActiveAgentSleepAssertion } from "./runtime/active-agent-sleep.js";
26
+ import { createTeammateInterrupter } from "./runtime/teammate-interrupt.js";
25
27
  export { panelBgFill, framePanel, frameWidget, frameWidgetFullWidth, logWindowStart } from "./ui/frame.js";
26
28
  import * as messaging from "../src/utils/messaging";
27
29
  import * as teams from "../src/utils/teams";
@@ -62,6 +64,9 @@ export default function (pi: ExtensionAPI) {
62
64
  if (clearedTeamName === teamName) void drainReadHelperQueueOnce();
63
65
  });
64
66
  const runningReadAgents = new Map<string, RunningReadAgent>();
67
+ // Tmux writers have no nested AgentSession, but status rendering already
68
+ // maintains a lifecycle-fenced view of their active roster/runtime state.
69
+ const navigationWriterAgents = new Map<string, RunningReadAgent>();
65
70
  const completedAgentReports = new Map<string, CompletedAgentReport[]>();
66
71
  const TEAM_ACTIVITY_RENDER_DEBOUNCE_MS = 75;
67
72
  let readAgentStatusTimer: NodeJS.Timeout | null = null;
@@ -91,8 +96,8 @@ export default function (pi: ExtensionAPI) {
91
96
  getProjectTrusted: (cwd) => parentProjectTrustForSpawn(sessionCtx, cwd),
92
97
  createResourcePlan: createCurrentSpawnResourcePlan,
93
98
  sleepController: activeAgentSleepController,
94
- onWriterActive: (tab) => upsertWriterScreenTab(writerScreenState, tab),
95
- onWriterInactive: (targetTeamName, member) => removeWriterScreenTab(writerScreenState, { teamName: targetTeamName, name: member.name, paneId: member.tmuxPaneId }),
99
+ onWriterActive: (tab) => { upsertWriterScreenTab(writerScreenState, tab); markTeamActivityStatusDirty(0); },
100
+ onWriterInactive: (targetTeamName, member) => { removeWriterScreenTab(writerScreenState, { teamName: targetTeamName, name: member.name, paneId: member.tmuxPaneId }); markTeamActivityStatusDirty(0); },
96
101
  });
97
102
  const { shutdownTeammate, startLeadWatchdog, stopLeadWatchdog } = createLifecycleRuntime({
98
103
  isTeammate,
@@ -108,6 +113,7 @@ export default function (pi: ExtensionAPI) {
108
113
  onWriterInactive: (targetTeamName, member) => {
109
114
  releaseWriteAgentSleepAssertion(targetTeamName, member);
110
115
  removeWriterScreenTab(writerScreenState, { teamName: targetTeamName, name: member.name, paneId: member.tmuxPaneId });
116
+ markTeamActivityStatusDirty(0);
111
117
  },
112
118
  onTeammateClosing: (targetTeamName, member) => {
113
119
  if (!member.lifecycleRunId) return;
@@ -140,6 +146,13 @@ export default function (pi: ExtensionAPI) {
140
146
  return `${targetTeamName}:${targetAgentName}`;
141
147
  }
142
148
 
149
+ const interruptTeammate = createTeammateInterrupter({
150
+ terminal,
151
+ runningReadAgents,
152
+ readAgentKey,
153
+ getTeamName: () => teamName,
154
+ });
155
+
143
156
  function runInProcessAgentWithSleepAssertion(
144
157
  ...args: Parameters<typeof runReadAgentInProcess>
145
158
  ): ReturnType<typeof runReadAgentInProcess> {
@@ -232,6 +245,17 @@ export default function (pi: ExtensionAPI) {
232
245
  && runtimeHeartbeatIsRecent(runtimeStatus, now);
233
246
  }
234
247
 
248
+ function navigationWriterAgent(teamName: string, member: Member, status: runtime.AgentRuntimeStatus): RunningReadAgent {
249
+ return {
250
+ runId: member.lifecycleRunId!, name: member.name, teamName,
251
+ startedAt: status.startedAt || member.joinedAt, tokensUsed: 0, contextUsage: status.contextUsage,
252
+ status: status.currentAction === "thinking" ? "thinking" : status.currentAction === "starting" ? "starting" : "working",
253
+ recentEvents: [], lastActivityAt: status.lastHeartbeatAt || member.joinedAt, role: "write",
254
+ model: member.model, thinking: member.thinking, modelSlot: canonicalPersistedModelSlot(member.modelSlot),
255
+ latestProgress: status.latestProgress,
256
+ };
257
+ }
258
+
235
259
  function runtimeOnlyStatusLabel(status: runtime.AgentRuntimeStatus, now: number): string {
236
260
  if (!runtimeHeartbeatIsRecent(status, now)) return "stale";
237
261
  if (status.currentAction && status.currentAction !== "done") return status.currentAction;
@@ -592,6 +616,12 @@ export default function (pi: ExtensionAPI) {
592
616
  .filter(member => member.name !== "team-lead" && member.isActive !== false && memberActivityRole(member) === "write")
593
617
  .filter(member => !!(member.tmuxPaneId && terminal?.isAlive?.(member.tmuxPaneId)) && !runningReadAgents.has(readAgentKey(activityTeamName, member.name)) && !runtimeOnlyMemberNames.has(member.name)) ?? []
594
618
  : [];
619
+ navigationWriterAgents.clear();
620
+ for (const { member, runtimeStatus } of runtimeOnlyWriteMembers) {
621
+ if (member.tmuxPaneId && member.lifecycleRunId === runtimeStatus.lifecycleRunId) {
622
+ navigationWriterAgents.set(member.name, navigationWriterAgent(activityTeamName!, member, runtimeStatus));
623
+ }
624
+ }
595
625
  const unreadLeadMessages = activityTeamName ? await messaging.readInbox(activityTeamName, agentName, true, false).catch(() => []) : [];
596
626
  leadInboxUnreadCount = unreadLeadMessages.length;
597
627
  clearLeadInboxWidgetOnce();
@@ -779,7 +809,9 @@ export default function (pi: ExtensionAPI) {
779
809
  }
780
810
  const level = requireFavoriteModelLevel(loadSettings({ projectDir: queued.cwd }), queued.modelSlot);
781
811
  if (level.role !== "read") throw new Error(`Read helper ${queued.name} requires a read-* intent tier configured via /agents-favorite-models, got ${level.slot}.`);
782
- const [provider, modelId] = level.model.split("/", 2);
812
+ const parsedModel = parseQualifiedModel(level.model);
813
+ const provider = parsedModel?.provider;
814
+ const modelId = parsedModel?.model;
783
815
  const model = provider && modelId ? sessionCtx.modelRegistry?.find?.(provider, modelId) : undefined;
784
816
  if (!model) throw new Error(`Read helper model \"${level.model}\" from intent tier ${level.slot} is not available in the lead session.`);
785
817
 
@@ -1084,8 +1116,20 @@ export default function (pi: ExtensionAPI) {
1084
1116
  if (!isTeammate) {
1085
1117
  pi.on("session_start", async (_event: any, ctx: any) => {
1086
1118
  installAgentNavigation(ctx, {
1087
- getAgents: () => Array.from(runningReadAgents.values())
1088
- .filter(agent => !teamName || agent.teamName === teamName),
1119
+ getAgents: () => {
1120
+ const readers = Array.from(runningReadAgents.values())
1121
+ .filter(agent => !teamName || agent.teamName === teamName);
1122
+ return [...readers, ...Array.from(navigationWriterAgents.values())
1123
+ .filter(agent => !teamName || agent.teamName === teamName)]
1124
+ .filter((agent, index, agents) => agents.findIndex(candidate => candidate.name === agent.name) === index);
1125
+ },
1126
+ interruptAgent: async (name: string) => {
1127
+ const result = await interruptTeammate(name);
1128
+ const warning = result.status === "pending"
1129
+ || result.status === "unsupported"
1130
+ || result.status === "failed";
1131
+ ctx.ui?.notify?.(result.message, warning ? "warning" : "info");
1132
+ },
1089
1133
  stopAgent: async (name: string) => {
1090
1134
  const targetTeamName = teamName;
1091
1135
  if (!targetTeamName) return;
@@ -1222,6 +1266,7 @@ export default function (pi: ExtensionAPI) {
1222
1266
  terminal,
1223
1267
  runningReadAgents,
1224
1268
  readAgentKey,
1269
+ interruptTeammate,
1225
1270
  shutdownTeammate,
1226
1271
  getTeamName: () => teamName,
1227
1272
  });
@@ -9,7 +9,7 @@ export const NESTED_READ_MODEL_SLOTS = [
9
9
 
10
10
  export type NestedReadModelSlot = (typeof NESTED_READ_MODEL_SLOTS)[number];
11
11
 
12
- export const NESTED_DELEGATION_TOOL_NAMES = ["spawn_agent", "spawn_swarm_agents"] as const;
12
+ export const NESTED_DELEGATION_TOOL_NAMES = ["get_agent_status", "spawn_agent", "spawn_swarm_agents"] as const;
13
13
 
14
14
  const ELIGIBLE_PARENT_MODEL_SLOTS = new Set(["write-feature", "write-critical"]);
15
15