pi-extended-teams 2.1.16

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 (130) hide show
  1. package/README.md +105 -0
  2. package/assets/pi-extended-teams-agent-navigation.png +0 -0
  3. package/assets/pi-extended-teams-in-action.png +0 -0
  4. package/extensions/agents/read-agent-report.ts +181 -0
  5. package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
  6. package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
  7. package/extensions/agents/read-agent.test.ts +3077 -0
  8. package/extensions/agents/read-agent.ts +1119 -0
  9. package/extensions/agents/write-agent.test.ts +513 -0
  10. package/extensions/agents/write-agent.ts +392 -0
  11. package/extensions/events/register-events.test.ts +465 -0
  12. package/extensions/events/register-events.ts +409 -0
  13. package/extensions/index.test.ts +1659 -0
  14. package/extensions/index.ts +1228 -0
  15. package/extensions/internal/agent-session-files.test.ts +164 -0
  16. package/extensions/internal/agent-session-files.ts +320 -0
  17. package/extensions/internal/debug.ts +44 -0
  18. package/extensions/internal/model-selection.ts +82 -0
  19. package/extensions/internal/pi-command.test.ts +191 -0
  20. package/extensions/internal/pi-command.ts +224 -0
  21. package/extensions/internal/pi-runtime-api.test.ts +43 -0
  22. package/extensions/internal/pi-runtime-api.ts +78 -0
  23. package/extensions/internal/schema.ts +19 -0
  24. package/extensions/internal/session-context-reference.test.ts +290 -0
  25. package/extensions/internal/session-context-reference.ts +430 -0
  26. package/extensions/internal/session-files.test.ts +225 -0
  27. package/extensions/internal/session-files.ts +273 -0
  28. package/extensions/internal/session-usage.ts +59 -0
  29. package/extensions/resources/spawn-resource-plan.test.ts +233 -0
  30. package/extensions/resources/spawn-resource-plan.ts +247 -0
  31. package/extensions/runtime/active-agent-sleep.test.ts +157 -0
  32. package/extensions/runtime/active-agent-sleep.ts +117 -0
  33. package/extensions/runtime/nested-read-agents.ts +22 -0
  34. package/extensions/runtime/pending-child-controller.test.ts +169 -0
  35. package/extensions/runtime/pending-child-controller.ts +280 -0
  36. package/extensions/runtime/types.ts +68 -0
  37. package/extensions/team/contracts.test.ts +111 -0
  38. package/extensions/team/lifecycle.test.ts +1095 -0
  39. package/extensions/team/lifecycle.ts +502 -0
  40. package/extensions/team/recipient-closure.test.ts +97 -0
  41. package/extensions/team/recipient-closure.ts +120 -0
  42. package/extensions/team/roster.test.ts +129 -0
  43. package/extensions/team/roster.ts +165 -0
  44. package/extensions/team/team-contracts.json +43 -0
  45. package/extensions/team/writer-screens.test.ts +50 -0
  46. package/extensions/team/writer-screens.ts +156 -0
  47. package/extensions/tools/agent-communication-tools.test.ts +306 -0
  48. package/extensions/tools/agent-communication-tools.ts +183 -0
  49. package/extensions/tools/coordination-tools.test.ts +670 -0
  50. package/extensions/tools/coordination-tools.ts +304 -0
  51. package/extensions/tools/delegation-guard.test.ts +95 -0
  52. package/extensions/tools/delegation-guard.ts +65 -0
  53. package/extensions/tools/file-claim-tools.test.ts +104 -0
  54. package/extensions/tools/file-claim-tools.ts +68 -0
  55. package/extensions/tools/model-tools.ts +53 -0
  56. package/extensions/tools/predefined-tools.test.ts +647 -0
  57. package/extensions/tools/predefined-tools.ts +331 -0
  58. package/extensions/tools/read-helper.test.ts +35 -0
  59. package/extensions/tools/task-runtime-tools.test.ts +334 -0
  60. package/extensions/tools/task-runtime-tools.ts +227 -0
  61. package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
  62. package/extensions/tools/team-tools.ts +1270 -0
  63. package/extensions/ui/agent-follow-view.test.ts +520 -0
  64. package/extensions/ui/agent-follow-view.ts +779 -0
  65. package/extensions/ui/agent-navigation.test.ts +52 -0
  66. package/extensions/ui/agent-navigation.ts +55 -0
  67. package/extensions/ui/ansi.ts +16 -0
  68. package/extensions/ui/extensions-command.test.ts +336 -0
  69. package/extensions/ui/extensions-command.ts +282 -0
  70. package/extensions/ui/favorite-models-command.test.ts +280 -0
  71. package/extensions/ui/favorite-models-command.ts +423 -0
  72. package/extensions/ui/frame.ts +77 -0
  73. package/extensions/ui/input.ts +17 -0
  74. package/extensions/ui/read-agent-status.test.ts +117 -0
  75. package/extensions/ui/read-agent-status.ts +125 -0
  76. package/extensions/ui/renderers.ts +243 -0
  77. package/extensions/ui/status-widget.test.ts +143 -0
  78. package/extensions/ui/status-widget.ts +440 -0
  79. package/extensions/ui-frame.test.ts +69 -0
  80. package/package.json +75 -0
  81. package/skills/teams.md +256 -0
  82. package/src/adapters/terminal-registry.ts +78 -0
  83. package/src/adapters/tmux-adapter.test.ts +276 -0
  84. package/src/adapters/tmux-adapter.ts +200 -0
  85. package/src/orchestration/index.ts +437 -0
  86. package/src/orchestration/orchestrator.test.ts +396 -0
  87. package/src/orchestration/types.ts +125 -0
  88. package/src/utils/atomic-json.ts +21 -0
  89. package/src/utils/claims.test.ts +137 -0
  90. package/src/utils/claims.ts +169 -0
  91. package/src/utils/hooks.test.ts +75 -0
  92. package/src/utils/hooks.ts +35 -0
  93. package/src/utils/lifecycle-tombstone.test.ts +105 -0
  94. package/src/utils/lifecycle-tombstone.ts +268 -0
  95. package/src/utils/lock.race.child.ts +44 -0
  96. package/src/utils/lock.race.test.ts +198 -0
  97. package/src/utils/lock.test.ts +90 -0
  98. package/src/utils/lock.ts +186 -0
  99. package/src/utils/messaging.test.ts +337 -0
  100. package/src/utils/messaging.ts +441 -0
  101. package/src/utils/model-resolution.test.ts +231 -0
  102. package/src/utils/model-resolution.ts +322 -0
  103. package/src/utils/models.test.ts +8 -0
  104. package/src/utils/models.ts +115 -0
  105. package/src/utils/paths.ts +81 -0
  106. package/src/utils/predefined-teams/types.ts +48 -0
  107. package/src/utils/predefined-teams.save-template.test.ts +74 -0
  108. package/src/utils/predefined-teams.test.ts +441 -0
  109. package/src/utils/predefined-teams.ts +471 -0
  110. package/src/utils/read-helper-queue.ts +99 -0
  111. package/src/utils/report-events.test.ts +154 -0
  112. package/src/utils/report-events.ts +222 -0
  113. package/src/utils/runtime.test.ts +314 -0
  114. package/src/utils/runtime.ts +261 -0
  115. package/src/utils/security.test.ts +43 -0
  116. package/src/utils/settings.test.ts +480 -0
  117. package/src/utils/settings.ts +645 -0
  118. package/src/utils/shared-memory.test.ts +80 -0
  119. package/src/utils/shared-memory.ts +81 -0
  120. package/src/utils/tasks.race.test.ts +44 -0
  121. package/src/utils/tasks.test.ts +229 -0
  122. package/src/utils/tasks.ts +396 -0
  123. package/src/utils/teams.ts +231 -0
  124. package/src/utils/terminal-adapter.ts +103 -0
  125. package/src/utils/thinking-levels.test.ts +56 -0
  126. package/src/utils/thinking-levels.ts +47 -0
  127. package/src/utils/workflow-metadata.test.ts +11 -0
  128. package/src/utils/workflow-metadata.ts +88 -0
  129. package/src/utils/write-queue.test.ts +251 -0
  130. package/src/utils/write-queue.ts +202 -0
@@ -0,0 +1,120 @@
1
+ import * as teams from "../../src/utils/teams";
2
+ import {
3
+ generateExtensionInstanceId,
4
+ withLifecycleTombstoneLock,
5
+ } from "../../src/utils/lifecycle-tombstone";
6
+
7
+ export interface ClosePersistedRecipientOptions {
8
+ removeOnFailure?: boolean;
9
+ role?: "read" | "write";
10
+ reason?: string;
11
+ extensionInstanceId?: string;
12
+ }
13
+
14
+ const FALLBACK_EXTENSION_INSTANCE_ID = generateExtensionInstanceId();
15
+
16
+ function errorText(error: unknown): string {
17
+ return error instanceof Error ? error.message : String(error);
18
+ }
19
+
20
+ export async function closePersistedRecipient(
21
+ teamName: string,
22
+ agentName: string,
23
+ expectedRunId: string,
24
+ options: ClosePersistedRecipientOptions = {}
25
+ ): Promise<void> {
26
+ await withLifecycleTombstoneLock(teamName, agentName, async lifecycleLock => {
27
+ const tombstoneInput = {
28
+ team: teamName,
29
+ agent: agentName,
30
+ runId: expectedRunId,
31
+ role: options.role ?? "read",
32
+ reason: options.reason ?? "quit",
33
+ extensionInstanceId: options.extensionInstanceId ?? FALLBACK_EXTENSION_INSTANCE_ID,
34
+ } as const;
35
+
36
+ if (!teams.teamExists(teamName)) {
37
+ lifecycleLock.occupy(tombstoneInput);
38
+ lifecycleLock.updateMatching(expectedRunId, { phase: "persistence_closed", error: undefined });
39
+ return;
40
+ }
41
+
42
+ const readExpectedMember = async () => {
43
+ const config = await teams.readConfig(teamName);
44
+ const member = config.members.find(item => item.name === agentName);
45
+ if (member && member.lifecycleRunId !== expectedRunId) {
46
+ throw new Error(
47
+ `Refusing to close ${agentName} in ${teamName}: expected run ${expectedRunId}, found ${member.lifecycleRunId || "missing run id"}.`
48
+ );
49
+ }
50
+ return member;
51
+ };
52
+
53
+ // Establish run identity under the recipient lock before creating a fence.
54
+ // A wrong-run caller performs zero shared mutation. Unreadable persistence
55
+ // cannot establish safety, so it is fenced fail-closed before returning.
56
+ let initialMember: Awaited<ReturnType<typeof readExpectedMember>>;
57
+ try {
58
+ initialMember = await readExpectedMember();
59
+ } catch (error) {
60
+ if (error instanceof Error && error.message.startsWith("Refusing to close")) throw error;
61
+ lifecycleLock.occupy(tombstoneInput);
62
+ const message = `Could not close message admission for ${agentName} in ${teamName}: verification failed: ${errorText(error)}`;
63
+ lifecycleLock.updateMatching(expectedRunId, { phase: "cleanup_failed", error: message });
64
+ throw new Error(message);
65
+ }
66
+
67
+ lifecycleLock.occupy(tombstoneInput);
68
+ if (!initialMember) {
69
+ lifecycleLock.updateMatching(expectedRunId, { phase: "persistence_closed", error: undefined });
70
+ return;
71
+ }
72
+
73
+ let updateError: unknown;
74
+ try {
75
+ await teams.updateMember(teamName, agentName, { isActive: false });
76
+ } catch (error) {
77
+ updateError = error;
78
+ }
79
+
80
+ let closed = false;
81
+ let verificationError: unknown;
82
+ try {
83
+ const member = await readExpectedMember();
84
+ closed = !member || member.isActive === false;
85
+ } catch (error) {
86
+ verificationError = error;
87
+ }
88
+ if (closed) {
89
+ lifecycleLock.updateMatching(expectedRunId, { phase: "persistence_closed", error: undefined });
90
+ return;
91
+ }
92
+
93
+ let removalError: unknown;
94
+ if (options.removeOnFailure) {
95
+ try {
96
+ await readExpectedMember();
97
+ await teams.removeMemberMatchingRun(teamName, agentName, expectedRunId);
98
+ } catch (error) {
99
+ removalError = error;
100
+ }
101
+ try {
102
+ if (!(await readExpectedMember())) {
103
+ lifecycleLock.updateMatching(expectedRunId, { phase: "persistence_closed", error: undefined });
104
+ return;
105
+ }
106
+ } catch (error) {
107
+ verificationError = error;
108
+ }
109
+ }
110
+
111
+ const causes = [
112
+ updateError ? `inactive update failed: ${errorText(updateError)}` : "inactive update did not close membership",
113
+ verificationError ? `verification failed: ${errorText(verificationError)}` : undefined,
114
+ removalError ? `removal failed: ${errorText(removalError)}` : undefined,
115
+ ].filter(Boolean).join("; ");
116
+ const message = `Could not close message admission for ${agentName} in ${teamName}: ${causes}`;
117
+ lifecycleLock.updateMatching(expectedRunId, { phase: "cleanup_failed", error: message });
118
+ throw new Error(message);
119
+ });
120
+ }
@@ -0,0 +1,129 @@
1
+ import { describe, expect, it, vi, afterEach } from "vitest";
2
+ import { buildRoster, requireWriteAgentTeam } from "./roster.js";
3
+ import * as teams from "../../src/utils/teams.js";
4
+ import * as tasks from "../../src/utils/tasks.js";
5
+ import * as claims from "../../src/utils/claims.js";
6
+ import * as runtime from "../../src/utils/runtime.js";
7
+ import * as messaging from "../../src/utils/messaging.js";
8
+ import * as writeQueue from "../../src/utils/write-queue.js";
9
+ import type { Member, TeamConfig, TaskFile } from "../../src/utils/models.js";
10
+ import type { FileClaim } from "../../src/utils/claims.js";
11
+
12
+ function member(name: string, overrides: Partial<Member> = {}): Member {
13
+ return {
14
+ agentId: `${name}@scale`,
15
+ name,
16
+ agentType: name === "team-lead" ? "lead" : "teammate",
17
+ role: name === "team-lead" ? undefined : "write",
18
+ joinedAt: Date.now(),
19
+ tmuxPaneId: name === "team-lead" ? "" : `%${name}`,
20
+ cwd: process.cwd(),
21
+ subscriptions: [],
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ describe("write-agent authorization", () => {
27
+ afterEach(() => {
28
+ vi.restoreAllMocks();
29
+ });
30
+
31
+ it("rejects an identity that is absent from the session roster", async () => {
32
+ vi.spyOn(teams, "readConfig").mockResolvedValue({
33
+ name: "session",
34
+ description: "",
35
+ createdAt: Date.now(),
36
+ leadAgentId: "lead",
37
+ leadSessionId: "lead-session",
38
+ members: [member("team-lead")],
39
+ });
40
+
41
+ await expect(requireWriteAgentTeam("session", true, "missing-writer"))
42
+ .rejects.toThrow("Agent missing-writer is not a member of session session.");
43
+ });
44
+
45
+ it("preserves the legacy missing-role fallback only for a real roster member", async () => {
46
+ vi.spyOn(teams, "readConfig").mockResolvedValue({
47
+ name: "session",
48
+ description: "",
49
+ createdAt: Date.now(),
50
+ leadAgentId: "lead",
51
+ leadSessionId: "lead-session",
52
+ members: [member("team-lead"), member("legacy-writer", { role: undefined })],
53
+ });
54
+
55
+ await expect(requireWriteAgentTeam("session", true, "legacy-writer")).resolves.toBe("session");
56
+ });
57
+
58
+ it("rejects a real read member", async () => {
59
+ vi.spyOn(teams, "readConfig").mockResolvedValue({
60
+ name: "session",
61
+ description: "",
62
+ createdAt: Date.now(),
63
+ leadAgentId: "lead",
64
+ leadSessionId: "lead-session",
65
+ members: [member("team-lead"), member("reader", { role: "read" })],
66
+ });
67
+
68
+ await expect(requireWriteAgentTeam("session", true, "reader"))
69
+ .rejects.toThrow("File claim tools are only available to write agents.");
70
+ });
71
+ });
72
+
73
+ describe("team roster performance", () => {
74
+ afterEach(() => {
75
+ vi.restoreAllMocks();
76
+ });
77
+
78
+ it("indexes tasks and claims once instead of filtering full lists per member", async () => {
79
+ const members = [
80
+ member("team-lead"),
81
+ ...Array.from({ length: 300 }, (_unused, index) => member(`agent-${index}`)),
82
+ ];
83
+ const config: TeamConfig = {
84
+ name: "scale",
85
+ description: "",
86
+ createdAt: Date.now(),
87
+ leadAgentId: "lead",
88
+ leadSessionId: "session",
89
+ members,
90
+ };
91
+ const taskRows: TaskFile[] = [
92
+ { id: "1", subject: "active", description: "", status: "in_progress", owner: "agent-42", blocks: [], blockedBy: [] },
93
+ { id: "2", subject: "pending", description: "", status: "pending", owner: "agent-42", blocks: [], blockedBy: [] },
94
+ { id: "3", subject: "completed", description: "", status: "completed", owner: "agent-42", blocks: [], blockedBy: [] },
95
+ { id: "4", subject: "deleted", description: "", status: "deleted", owner: "agent-99", blocks: [], blockedBy: [] },
96
+ { id: "5", subject: "unowned", description: "", status: "pending", blocks: [], blockedBy: [] },
97
+ ];
98
+ const claimRows: FileClaim[] = [
99
+ { agent: "agent-42", path: "extensions/team/roster.ts", since: 1 },
100
+ { agent: "agent-99", path: "extensions/team/lifecycle.ts", since: 2 },
101
+ ];
102
+ const taskFilterSpy = vi.spyOn(taskRows, "filter");
103
+ const claimFilterSpy = vi.spyOn(claimRows, "filter");
104
+
105
+ vi.spyOn(teams, "readConfig").mockResolvedValue(config);
106
+ vi.spyOn(tasks, "listTasks").mockResolvedValue(taskRows);
107
+ vi.spyOn(claims, "listClaims").mockResolvedValue(claimRows);
108
+ vi.spyOn(writeQueue, "listWriteQueue").mockResolvedValue([]);
109
+ vi.spyOn(runtime, "readRuntimeStatus").mockResolvedValue({ ready: true } as any);
110
+ vi.spyOn(messaging, "readInbox").mockResolvedValue([]);
111
+
112
+ const roster = await buildRoster("scale", {
113
+ terminal: { isAlive: vi.fn(() => true) },
114
+ runningReadAgents: new Map(),
115
+ readAgentKey: (teamName: string, agentName: string) => `${teamName}:${agentName}`,
116
+ });
117
+
118
+ const agent42 = roster.members.find((item) => item.name === "agent-42");
119
+ expect(agent42?.tasks).toEqual([
120
+ { id: "1", subject: "active", status: "in_progress" },
121
+ { id: "2", subject: "pending", status: "pending" },
122
+ ]);
123
+ expect(agent42?.claims).toEqual(["extensions/team/roster.ts"]);
124
+ expect(roster.members.find((item) => item.name === "agent-99")?.tasks).toEqual([]);
125
+ expect(roster.members.find((item) => item.name === "agent-99")?.claims).toEqual(["extensions/team/lifecycle.ts"]);
126
+ expect(taskFilterSpy).not.toHaveBeenCalled();
127
+ expect(claimFilterSpy).not.toHaveBeenCalled();
128
+ });
129
+ });
@@ -0,0 +1,165 @@
1
+ import * as teams from "../../src/utils/teams";
2
+ import * as tasks from "../../src/utils/tasks";
3
+ import * as messaging from "../../src/utils/messaging";
4
+ import * as runtime from "../../src/utils/runtime";
5
+ import * as claims from "../../src/utils/claims";
6
+ import * as writeQueue from "../../src/utils/write-queue";
7
+ import type { TaskFile } from "../../src/utils/models";
8
+ import type { FileClaim } from "../../src/utils/claims";
9
+ import type { RunningReadAgent } from "../runtime/types";
10
+ import { listLifecycleTombstones } from "../../src/utils/lifecycle-tombstone";
11
+
12
+ type RosterTask = Pick<TaskFile, "id" | "subject" | "status">;
13
+
14
+ export interface BuildRosterOptions {
15
+ terminal: any;
16
+ runningReadAgents: Map<string, RunningReadAgent>;
17
+ readAgentKey(teamName: string, agentName: string): string;
18
+ }
19
+
20
+ export function requireTeamContext(currentTeamName: string | null | undefined, explicitTeamName?: string): string {
21
+ const targetTeamName = explicitTeamName || currentTeamName;
22
+ if (!targetTeamName) {
23
+ throw new Error("No team name supplied and no current team context detected.");
24
+ }
25
+ return targetTeamName;
26
+ }
27
+
28
+ export async function requireWriteAgentTeam(
29
+ teamName: string | null | undefined,
30
+ isTeammate: boolean,
31
+ agentName: string
32
+ ): Promise<string> {
33
+ if (!teamName) {
34
+ throw new Error("No team context available for file claims.");
35
+ }
36
+ if (!isTeammate) {
37
+ throw new Error("claim_file and release_file are only available to teammates; leads should use list_file_claims.");
38
+ }
39
+
40
+ const teamConfig = await teams.readConfig(teamName);
41
+ const member = teamConfig.members.find(m => m.name === agentName);
42
+ if (!member) {
43
+ throw new Error(`Agent ${agentName} is not a member of session ${teamName}.`);
44
+ }
45
+ const role = member.role ?? "write";
46
+ if (role !== "write") {
47
+ throw new Error("File claim tools are only available to write agents.");
48
+ }
49
+
50
+ return teamName;
51
+ }
52
+
53
+ export async function releaseAllClaimsForAgent(teamName: string, agentName: string): Promise<string[]> {
54
+ return claims.releaseAllForAgent(teamName, agentName);
55
+ }
56
+
57
+ export function isWriteMemberAlive(member: any, terminal: any): boolean {
58
+ return !!(member.tmuxPaneId && terminal?.isAlive?.(member.tmuxPaneId));
59
+ }
60
+
61
+ export async function countWriteMembers(teamName: string, terminal?: any): Promise<number> {
62
+ const config = await teams.readConfig(teamName);
63
+ return config.members.filter(member => {
64
+ if (member.agentType !== "teammate" || (member.role ?? "write") !== "write") return false;
65
+ return terminal ? isWriteMemberAlive(member, terminal) : true;
66
+ }).length;
67
+ }
68
+
69
+ function indexOpenTasksByOwner(allTasks: TaskFile[]): Map<string, RosterTask[]> {
70
+ const tasksByOwner = new Map<string, RosterTask[]>();
71
+
72
+ for (const task of allTasks) {
73
+ if (!task.owner || task.status === "completed" || task.status === "deleted") continue;
74
+ const memberTasks = tasksByOwner.get(task.owner) ?? [];
75
+ memberTasks.push({ id: task.id, subject: task.subject, status: task.status });
76
+ if (memberTasks.length === 1) tasksByOwner.set(task.owner, memberTasks);
77
+ }
78
+
79
+ return tasksByOwner;
80
+ }
81
+
82
+ function indexClaimsByAgent(allClaims: FileClaim[]): Map<string, string[]> {
83
+ const claimsByAgent = new Map<string, string[]>();
84
+
85
+ for (const claim of allClaims) {
86
+ const memberClaims = claimsByAgent.get(claim.agent) ?? [];
87
+ memberClaims.push(claim.path);
88
+ if (memberClaims.length === 1) claimsByAgent.set(claim.agent, memberClaims);
89
+ }
90
+
91
+ return claimsByAgent;
92
+ }
93
+
94
+ export async function buildRoster(teamName: string, options: BuildRosterOptions) {
95
+ const config = await teams.readConfig(teamName);
96
+ const allTasks = await tasks.listTasks(teamName).catch(() => []);
97
+ const allClaims = await claims.listClaims(teamName).catch(() => []);
98
+ const queue = await writeQueue.listWriteQueue(teamName).catch(() => []);
99
+ const tasksByOwner = indexOpenTasksByOwner(allTasks);
100
+ const claimsByAgent = indexClaimsByAgent(allClaims);
101
+ const tombstones = await listLifecycleTombstones(teamName).catch(() => []);
102
+ const tombstoneByAgent = new Map(tombstones.map(entry => [entry.agentName, entry.result]));
103
+
104
+ const members = await Promise.all(config.members.map(async (member) => {
105
+ const role = member.role ?? (member.name === "team-lead" ? "lead" : "write");
106
+ const runtimeStatus = member.name === "team-lead" ? null : await runtime.readRuntimeStatus(teamName, member.name).catch(() => null);
107
+ const unreadCount = member.name === "team-lead" ? 0 : (await messaging.readInbox(teamName, member.name, true, false).catch(() => [])).length;
108
+ const memberTasks = tasksByOwner.get(member.name) ?? [];
109
+ const memberClaims = claimsByAgent.get(member.name) ?? [];
110
+ const readState = options.runningReadAgents.get(options.readAgentKey(teamName, member.name));
111
+ const alive = member.name === "team-lead"
112
+ ? true
113
+ : !!readState || !!runtimeStatus?.ready || (role === "write" && isWriteMemberAlive(member, options.terminal));
114
+
115
+ const tombstone = tombstoneByAgent.get(member.name);
116
+ return {
117
+ name: member.name,
118
+ role,
119
+ status: member.name === "team-lead" ? "lead" : tombstone ? "quarantined" : alive ? (readState?.status || "running") : "dead/idle",
120
+ model: member.model,
121
+ cwd: member.cwd,
122
+ unreadCount,
123
+ tasks: memberTasks,
124
+ claims: memberClaims,
125
+ tmuxPaneId: member.tmuxPaneId || undefined,
126
+ runtime: runtimeStatus,
127
+ };
128
+ }));
129
+
130
+ const knownNames = new Set(config.members.map(member => member.name));
131
+ for (const { agentName, result } of tombstones) {
132
+ if (knownNames.has(agentName)) continue;
133
+ members.push({
134
+ name: agentName,
135
+ role: result.status === "occupied" ? result.tombstone.role : "read",
136
+ status: "quarantined",
137
+ model: undefined,
138
+ cwd: "",
139
+ unreadCount: 0,
140
+ tasks: tasksByOwner.get(agentName) ?? [],
141
+ claims: claimsByAgent.get(agentName) ?? [],
142
+ tmuxPaneId: undefined,
143
+ runtime: await runtime.readRuntimeStatus(teamName, agentName).catch(() => null),
144
+ });
145
+ }
146
+
147
+ return {
148
+ teamName,
149
+ members,
150
+ writeQueue: queue.map((item, index) => ({ position: index + 1, id: item.id, name: item.name, requestedAt: item.requestedAt })),
151
+ };
152
+ }
153
+
154
+ export function formatRosterForPrompt(roster: Awaited<ReturnType<typeof buildRoster>>): string {
155
+ const lines = [`Agents for session ${roster.teamName}:`];
156
+ for (const member of roster.members) {
157
+ const taskText = member.tasks.length > 0 ? ` tasks=${member.tasks.map(task => `#${task.id}:${task.status}`).join(",")}` : "";
158
+ const claimText = member.claims.length > 0 ? ` claims=${member.claims.join(",")}` : "";
159
+ lines.push(`- ${member.name}: ${member.role}, ${member.status}, unread=${member.unreadCount}${taskText}${claimText}`);
160
+ }
161
+ if (roster.writeQueue.length > 0) {
162
+ lines.push(`Queued edit agents: ${roster.writeQueue.map(item => `${item.position}.${item.name}`).join(", ")}`);
163
+ }
164
+ return lines.join("\n");
165
+ }
@@ -0,0 +1,43 @@
1
+ {
2
+ "version": 1,
3
+ "updatedAt": "2026-07-12T00:00:00.000Z",
4
+ "source": "Leo Pereira session requirements",
5
+ "contracts": [
6
+ {
7
+ "id": "ui.team-activity-card",
8
+ "description": "Live team activity renders as a stable below-editor pi-emote-style card with always-open bounded details. It keeps the card design with its separator at the bottom (not an extra bar above the card), stays mounted while agents are active, and updates progress, elapsed time, and token counts in place without flicker. Each active writer row shows +N for its currently active, runtime-attributed depth-1 read helpers without changing global active/read/write counts; queued, completed, failed, stale, and historical helpers do not contribute. New progress phrases transition in one second by quickly fading/removing the old phrase and revealing the new phrase left-to-right, then resume the repeating one/two/three-dot animation. Completed reports arrive as messages/open reports and do not create report-ready or waiting-confirmation status rows."
9
+ },
10
+ {
11
+ "id": "ui.active-reader-writer-info",
12
+ "description": "Active reader and writer information remains available in the team activity card and Down-key live navigation, including elapsed/running time, current status/action, model, thinking level, and token count when available."
13
+ },
14
+ {
15
+ "id": "ui.agent-follow-navigation",
16
+ "description": "From an empty main editor, Down opens the first active agent in a full-window interactive live-follow view. Down moves to the next agent; Up moves to the previous agent and returns to main only from the first agent; Escape always returns to main. The view exposes transcript, thinking, progress, elapsed time, and tokens; pairs tool calls with their results in readable blocks; collapses large results with head/tail context and an expansion toggle; and provides an input for direct messages to the selected active agent. Pressing x stops the selected subagent while keeping the live view open."
17
+ },
18
+ {
19
+ "id": "helpers.requested-by-writer",
20
+ "description": "A read helper spawned for a writer records and displays requestedBy metadata as requested by <writer>."
21
+ },
22
+ {
23
+ "id": "writers.request-only-lead-spawns",
24
+ "description": "Nested delegation is off by default. Only explicitly opted-in depth-0 write-feature/write-critical agents receive restricted read-only spawn_agent/spawn_swarm_agents tools; they may request any canonical read tier and any count subject to global capacity. Read agents, depth-1 children, write-patch, and write-system remain denied. Children report to the requesting writer and cannot delegate."
25
+ },
26
+ {
27
+ "id": "messaging.push-on-completion",
28
+ "description": "Readers send completion messages to the lead; read helpers for writers send the full report to the requesting writer and a classified completion notice to the lead. Every newly unread agent message injects a deduplicated user-style follow-up into the lead prompt, even while the lead is busy, instead of relying on manual polling."
29
+ },
30
+ {
31
+ "id": "completion.remove-from-active-keep-history",
32
+ "description": "When a reader or writer is done it is removed from active UI/status, while its final report remains visible as a pushed report message in the lead session."
33
+ },
34
+ {
35
+ "id": "runtime.reload-disposes-background-handles",
36
+ "description": "Session shutdown clears the lead watchdog, teammate inbox poll/wake/title/initial-trigger timers, teammate inbox file watcher, and all active in-process agent sessions/heartbeats so extension reload cannot leave stale agents, model work, filesystem work, prompt wakes, or file descriptors active."
37
+ },
38
+ {
39
+ "id": "writers.tmux-closes-when-done",
40
+ "description": "Writer background tmux screens close only after the writer sends its final report and completes cleanup."
41
+ }
42
+ ]
43
+ }
@@ -0,0 +1,50 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createWriterScreenState, cycleWriterScreen, upsertWriterScreenTab } from "./writer-screens.js";
3
+ import * as teams from "../../src/utils/teams.js";
4
+
5
+ function ignorePersistedTeamConfig() {
6
+ vi.spyOn(teams, "readConfig").mockRejectedValue(new Error("not found"));
7
+ }
8
+
9
+ describe("writer screen cycling", () => {
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ });
13
+
14
+ it("cycles extension-owned writer tabs without reading the current tmux pane", async () => {
15
+ ignorePersistedTeamConfig();
16
+ const state = createWriterScreenState();
17
+ upsertWriterScreenTab(state, { teamName: "team", name: "writer-1", paneId: "%1", windowId: "@1", joinedAt: 1 });
18
+ upsertWriterScreenTab(state, { teamName: "team", name: "writer-2", paneId: "%2", windowId: "@2", joinedAt: 2 });
19
+
20
+ const terminal = {
21
+ getCurrentPaneId: vi.fn(() => "%lead"),
22
+ isAlive: vi.fn(() => true),
23
+ focusPane: vi.fn((_paneId: string) => true),
24
+ };
25
+
26
+ await expect(cycleWriterScreen("team", terminal, state)).resolves.toMatchObject({ ok: true, target: { name: "writer-1", paneId: "%1" } });
27
+ await expect(cycleWriterScreen("team", terminal, state)).resolves.toMatchObject({ ok: true, target: { name: "writer-2", paneId: "%2" } });
28
+
29
+ expect(terminal.focusPane.mock.calls.map(([paneId]) => paneId)).toEqual(["%1", "%2"]);
30
+ expect(terminal.getCurrentPaneId).not.toHaveBeenCalled();
31
+ });
32
+
33
+ it("prunes dead writer tabs before cycling", async () => {
34
+ ignorePersistedTeamConfig();
35
+ const state = createWriterScreenState();
36
+ upsertWriterScreenTab(state, { teamName: "team", name: "gone", paneId: "%gone" });
37
+ upsertWriterScreenTab(state, { teamName: "team", name: "writer", paneId: "%1" });
38
+
39
+ const terminal = {
40
+ isAlive: vi.fn((paneId: string) => paneId !== "%gone"),
41
+ focusPane: vi.fn((_paneId: string) => true),
42
+ };
43
+
44
+ const result = await cycleWriterScreen("team", terminal, state);
45
+
46
+ expect(result).toMatchObject({ ok: true, target: { name: "writer", paneId: "%1" } });
47
+ expect(state.activeWritersTabs.map(tab => tab.paneId)).toEqual(["%1"]);
48
+ expect(terminal.focusPane).toHaveBeenCalledWith("%1");
49
+ });
50
+ });
@@ -0,0 +1,156 @@
1
+ import { Key } from "@mariozechner/pi-tui";
2
+ import * as teams from "../../src/utils/teams";
3
+
4
+ export interface ActiveWriterTab {
5
+ teamName: string;
6
+ name: string;
7
+ paneId: string;
8
+ windowId?: string;
9
+ joinedAt?: number;
10
+ }
11
+
12
+ export interface WriterScreenTarget extends ActiveWriterTab {
13
+ role: "write";
14
+ }
15
+
16
+ export interface WriterScreenCycleResult {
17
+ ok: boolean;
18
+ reason?: string;
19
+ target?: WriterScreenTarget;
20
+ }
21
+
22
+ export interface WriterScreenState {
23
+ activeWritersTabs: ActiveWriterTab[];
24
+ cursor: number;
25
+ }
26
+
27
+ export function createWriterScreenState(activeWritersTabs: ActiveWriterTab[] = []): WriterScreenState {
28
+ return { activeWritersTabs, cursor: -1 };
29
+ }
30
+
31
+ const defaultWriterScreenState = createWriterScreenState();
32
+
33
+ function normalizeWriterTab(tab: ActiveWriterTab): ActiveWriterTab | null {
34
+ const teamName = tab.teamName?.trim();
35
+ const name = tab.name?.trim();
36
+ const paneId = tab.paneId?.trim();
37
+ if (!teamName || !name || !paneId) return null;
38
+ return {
39
+ ...tab,
40
+ teamName,
41
+ name,
42
+ paneId,
43
+ windowId: tab.windowId?.trim() || undefined,
44
+ };
45
+ }
46
+
47
+ function replaceActiveWriterTabs(state: WriterScreenState, nextTabs: ActiveWriterTab[]): void {
48
+ state.activeWritersTabs.splice(0, state.activeWritersTabs.length, ...nextTabs);
49
+ if (state.activeWritersTabs.length === 0) state.cursor = -1;
50
+ else if (state.cursor >= state.activeWritersTabs.length) state.cursor = state.activeWritersTabs.length - 1;
51
+ }
52
+
53
+ function isPaneAlive(terminal: any, paneId: string | undefined): paneId is string {
54
+ if (!paneId) return false;
55
+ if (!terminal?.isAlive) return true;
56
+ try {
57
+ return !!terminal.isAlive(paneId);
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ async function readLiveWriterMemberNames(teamName: string): Promise<Set<string> | undefined> {
64
+ try {
65
+ const config = await teams.readConfig(teamName);
66
+ return new Set(
67
+ config.members
68
+ .filter(member => member.name !== "team-lead" && (member.role ?? "write") !== "read")
69
+ .map(member => member.name)
70
+ );
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ export function upsertWriterScreenTab(state: WriterScreenState, tab: ActiveWriterTab): void {
77
+ const normalized = normalizeWriterTab(tab);
78
+ if (!normalized) return;
79
+
80
+ const existingIndex = state.activeWritersTabs.findIndex((item) => (
81
+ item.teamName === normalized.teamName && (item.name === normalized.name || item.paneId === normalized.paneId)
82
+ ));
83
+
84
+ if (existingIndex >= 0) {
85
+ state.activeWritersTabs.splice(existingIndex, 1, normalized);
86
+ return;
87
+ }
88
+
89
+ state.activeWritersTabs.push(normalized);
90
+ }
91
+
92
+ export function removeWriterScreenTab(state: WriterScreenState, match: { teamName?: string; name?: string; paneId?: string }): void {
93
+ const nextTabs = state.activeWritersTabs.filter((tab) => {
94
+ if (match.teamName && tab.teamName !== match.teamName) return true;
95
+ if (match.name && tab.name !== match.name) return true;
96
+ if (match.paneId && tab.paneId !== match.paneId) return true;
97
+ return false;
98
+ });
99
+ replaceActiveWriterTabs(state, nextTabs);
100
+ }
101
+
102
+ export async function pruneWriterScreenTabs(teamName: string, terminal: any, state = defaultWriterScreenState): Promise<ActiveWriterTab[]> {
103
+ const liveWriterNames = await readLiveWriterMemberNames(teamName);
104
+ const nextTabs = state.activeWritersTabs.filter((tab) => {
105
+ if (tab.teamName !== teamName) return true;
106
+ if (liveWriterNames && !liveWriterNames.has(tab.name)) return false;
107
+ return isPaneAlive(terminal, tab.paneId);
108
+ });
109
+ replaceActiveWriterTabs(state, nextTabs);
110
+ return state.activeWritersTabs.filter(tab => tab.teamName === teamName);
111
+ }
112
+
113
+ export async function buildWriterScreenTargets(teamName: string, terminal: any, state = defaultWriterScreenState): Promise<WriterScreenTarget[]> {
114
+ const tabs = await pruneWriterScreenTabs(teamName, terminal, state);
115
+ return tabs.map(tab => ({ ...tab, role: "write" as const }));
116
+ }
117
+
118
+ export async function cycleWriterScreen(teamName: string | null | undefined, terminal: any, state = defaultWriterScreenState): Promise<WriterScreenCycleResult> {
119
+ if (!teamName) return { ok: false, reason: "No current team to cycle." };
120
+ if (!terminal?.focusPane) return { ok: false, reason: "The active terminal adapter cannot focus tmux screens." };
121
+
122
+ const targets = await buildWriterScreenTargets(teamName, terminal, state);
123
+ if (targets.length === 0) return { ok: false, reason: `No active writer screens for ${teamName}.` };
124
+
125
+ state.cursor = (state.cursor + 1) % targets.length;
126
+ const target = targets[state.cursor] ?? targets[0];
127
+ if (!target) return { ok: false, reason: `No focusable writer screens for ${teamName}.` };
128
+
129
+ const focused = terminal.focusPane(target.paneId);
130
+ if (!focused) {
131
+ removeWriterScreenTab(state, { teamName: target.teamName, name: target.name, paneId: target.paneId });
132
+ return { ok: false, reason: `Could not focus ${target.name} (${target.paneId}).`, target };
133
+ }
134
+
135
+ return { ok: true, target };
136
+ }
137
+
138
+ export function registerWriterScreenShortcut(pi: any, options: {
139
+ getTeamName(): string | null | undefined;
140
+ terminal: any;
141
+ state?: WriterScreenState;
142
+ }): void {
143
+ pi.registerShortcut?.(Key.alt("tab"), {
144
+ description: "Cycle active writer-agent tmux screens",
145
+ handler: async (ctx: any) => {
146
+ const result = await cycleWriterScreen(options.getTeamName(), options.terminal, options.state ?? defaultWriterScreenState);
147
+ if (!result.ok) {
148
+ ctx.ui.notify(result.reason || "Could not cycle writer screens.", "warning");
149
+ return;
150
+ }
151
+
152
+ const target = result.target!;
153
+ ctx.ui.notify(`Switched to writer ${target.name} (${target.paneId}).`, "info");
154
+ },
155
+ });
156
+ }