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,169 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createPendingChildController, type ParentRunIdentity } from "./pending-child-controller.js";
3
+
4
+ function parent(overrides: Partial<ParentRunIdentity> = {}): ParentRunIdentity {
5
+ return {
6
+ teamName: "team",
7
+ parentName: "writer",
8
+ parentRunId: "parent-run",
9
+ ...overrides,
10
+ };
11
+ }
12
+
13
+ describe("pending child controller", () => {
14
+ it("registers acceptance before exact-run binding and settles a fast child without losing the empty event", async () => {
15
+ const controller = createPendingChildController();
16
+ const identity = parent();
17
+ const accepted = controller.acceptChild(identity, "fast-child");
18
+ const waiting = controller.waitForEmptyOrCancelled(identity);
19
+
20
+ expect(controller.pendingCount(identity)).toBe(1);
21
+ const run = controller.bindAcceptedChild(accepted, "fast-child", "fast-run");
22
+ expect(run).toEqual(expect.objectContaining({ childName: "fast-child", childRunId: "fast-run" }));
23
+ expect(controller.settleChildRun(run!)).toBe(true);
24
+
25
+ await expect(waiting).resolves.toBe("empty");
26
+ expect(controller.hasPendingChildren(identity)).toBe(false);
27
+ expect(controller.trackedParentCount()).toBe(1);
28
+ expect(controller.forgetParent(identity)).toBe(true);
29
+ expect(controller.trackedParentCount()).toBe(0);
30
+ });
31
+
32
+ it("keeps a queued token pending while transferring it atomically to the assigned lifecycle run", async () => {
33
+ const controller = createPendingChildController();
34
+ const identity = parent();
35
+ const queued = controller.acceptChild(identity, "queued-child");
36
+ const waiting = controller.waitForEmptyOrCancelled(identity);
37
+ let latchSettled = false;
38
+ void waiting.then(() => { latchSettled = true; });
39
+
40
+ const promoted = controller.bindAcceptedChild(queued, "queued-child", "promoted-run");
41
+ await Promise.resolve();
42
+ expect(latchSettled).toBe(false);
43
+ expect(controller.pendingCount(identity)).toBe(1);
44
+
45
+ expect(controller.settleChildRun(promoted!)).toBe(true);
46
+ await expect(waiting).resolves.toBe("empty");
47
+ expect(controller.forgetParent(identity)).toBe(true);
48
+ });
49
+
50
+ it("requires exact child-run settlement so stale run A cannot clear same-name run B", async () => {
51
+ const controller = createPendingChildController();
52
+ const identity = parent();
53
+ const acceptedA = controller.acceptChild(identity, "same-name");
54
+ const runA = controller.bindAcceptedChild(acceptedA, "same-name", "run-a")!;
55
+ expect(controller.settleChildRun(runA)).toBe(true);
56
+
57
+ const acceptedB = controller.acceptChild(identity, "same-name");
58
+ const runB = controller.bindAcceptedChild(acceptedB, "same-name", "run-b")!;
59
+ const waitingForB = controller.waitForEmptyOrCancelled(identity);
60
+
61
+ expect(controller.settleChildRun(runA)).toBe(false);
62
+ expect(controller.pendingCount(identity)).toBe(1);
63
+ expect(controller.settleChildRun(runB)).toBe(true);
64
+ await expect(waitingForB).resolves.toBe("empty");
65
+ expect(controller.forgetParent(identity)).toBe(true);
66
+ });
67
+
68
+ it("cancels waiters immediately, notifies once, and leaves duplicate cleanup idempotent", async () => {
69
+ const controller = createPendingChildController();
70
+ const identity = parent();
71
+ const cancelled = vi.fn();
72
+ controller.onParentCancelled(cancelled);
73
+ const accepted = controller.acceptChild(identity, "queued-child");
74
+ const waiting = controller.waitForEmptyOrCancelled(identity);
75
+
76
+ expect(controller.cancelParent(identity)).toBe(true);
77
+ expect(controller.cancelParent(identity)).toBe(false);
78
+ expect(controller.settleAcceptance(accepted)).toBe(false);
79
+ await expect(waiting).resolves.toBe("cancelled");
80
+ expect(cancelled).toHaveBeenCalledOnce();
81
+ expect(controller.pendingCount(identity)).toBe(0);
82
+
83
+ expect(controller.forgetParent(identity)).toBe(true);
84
+ expect(controller.trackedParentCount()).toBe(0);
85
+ });
86
+
87
+ it("creates a durable unseen cancellation sentinel, notifies once, and isolates a newer same-name run", async () => {
88
+ const controller = createPendingChildController();
89
+ const exactRun = parent();
90
+ const newerRun = parent({ parentRunId: "parent-run-newer" });
91
+ const cancelled = vi.fn();
92
+ controller.onParentCancelled(cancelled);
93
+
94
+ expect(controller.cancelParent(exactRun)).toBe(true);
95
+ expect(controller.cancelParent(exactRun)).toBe(false);
96
+ expect(controller.trackedParentCount()).toBe(1);
97
+ expect(controller.observeParent(exactRun)).toMatchObject({ cancelled: true, pendingCount: 0 });
98
+ expect(() => controller.acceptChild(exactRun, "late-child")).toThrow("is closing");
99
+ expect(cancelled).toHaveBeenCalledOnce();
100
+ expect(cancelled).toHaveBeenCalledWith(exactRun);
101
+
102
+ const newerAcceptance = controller.acceptChild(newerRun, "newer-child");
103
+ const newerChild = controller.bindAcceptedChild(newerAcceptance, "newer-child", "newer-child-run");
104
+ expect(newerChild).toBeDefined();
105
+ expect(controller.pendingCount(newerRun)).toBe(1);
106
+ expect(controller.observeParent(newerRun).cancelled).toBe(false);
107
+ expect(controller.observeParent(exactRun).cancelled).toBe(true);
108
+
109
+ expect(controller.settleChildRun(newerChild!)).toBe(true);
110
+ expect(controller.trackedParentCount()).toBe(2);
111
+ expect(controller.forgetParent(newerRun)).toBe(true);
112
+ expect(controller.trackedParentCount()).toBe(1);
113
+ expect(controller.observeParent(exactRun).cancelled).toBe(true);
114
+ expect(controller.forgetParent(exactRun)).toBe(true);
115
+ expect(controller.trackedParentCount()).toBe(0);
116
+ });
117
+
118
+ it("wakes and rearms exact-parent generation waiters for accept, bind, settle, signal, and cancel", async () => {
119
+ const controller = createPendingChildController();
120
+ const identity = parent();
121
+ let snapshot = controller.observeParent(identity);
122
+
123
+ let changed = controller.waitForChangeOrCancelled(identity, snapshot.generation);
124
+ const acceptance = controller.acceptChild(identity, "child");
125
+ await expect(changed).resolves.toMatchObject({ status: "changed" });
126
+
127
+ snapshot = controller.observeParent(identity);
128
+ changed = controller.waitForChangeOrCancelled(identity, snapshot.generation);
129
+ const child = controller.bindAcceptedChild(acceptance, "child", "child-run")!;
130
+ await expect(changed).resolves.toMatchObject({ status: "changed" });
131
+
132
+ snapshot = controller.observeParent(identity);
133
+ changed = controller.waitForChangeOrCancelled(identity, snapshot.generation);
134
+ expect(controller.settleChildRun(child)).toBe(true);
135
+ await expect(changed).resolves.toMatchObject({ status: "changed" });
136
+
137
+ snapshot = controller.observeParent(identity);
138
+ changed = controller.waitForChangeOrCancelled(identity, snapshot.generation);
139
+ expect(controller.signalParentChange(identity)).toBe(true);
140
+ await expect(changed).resolves.toMatchObject({ status: "changed" });
141
+
142
+ snapshot = controller.observeParent(identity);
143
+ changed = controller.waitForChangeOrCancelled(identity, snapshot.generation);
144
+ expect(controller.cancelParent(identity)).toBe(true);
145
+ await expect(changed).resolves.toMatchObject({ status: "cancelled" });
146
+ expect(controller.trackedParentCount()).toBe(1);
147
+ expect(controller.forgetParent(identity)).toBe(true);
148
+ });
149
+
150
+ it("reclaims only an exact zero-child state and retains cancelled quarantine until successful forget", () => {
151
+ const controller = createPendingChildController();
152
+ const quarantined = parent({ parentRunId: "quarantined-run" });
153
+ const active = parent({ parentRunId: "active-run" });
154
+ const acceptance = controller.acceptChild(active, "child");
155
+
156
+ expect(controller.cancelParent(quarantined)).toBe(true);
157
+ expect(controller.forgetParent(active)).toBe(false);
158
+ expect(controller.trackedParentCount()).toBe(2);
159
+ expect(controller.settleAcceptance(acceptance)).toBe(true);
160
+ expect(controller.trackedParentCount()).toBe(2);
161
+ expect(controller.observeParent(quarantined).cancelled).toBe(true);
162
+
163
+ expect(controller.forgetParent(active)).toBe(true);
164
+ expect(controller.trackedParentCount()).toBe(1);
165
+ expect(controller.observeParent(quarantined).cancelled).toBe(true);
166
+ expect(controller.forgetParent(quarantined)).toBe(true);
167
+ expect(controller.trackedParentCount()).toBe(0);
168
+ });
169
+ });
@@ -0,0 +1,280 @@
1
+ import crypto from "node:crypto";
2
+
3
+ export interface ParentRunIdentity {
4
+ teamName: string;
5
+ parentName: string;
6
+ parentRunId: string;
7
+ }
8
+
9
+ export interface PendingChildAcceptance extends ParentRunIdentity {
10
+ token: string;
11
+ childName: string;
12
+ }
13
+
14
+ export interface PendingChildRun extends ParentRunIdentity {
15
+ childName: string;
16
+ childRunId: string;
17
+ }
18
+
19
+ export type PendingChildrenLatchResult = "empty" | "cancelled";
20
+
21
+ export interface PendingParentSnapshot {
22
+ generation: number;
23
+ pendingCount: number;
24
+ cancelled: boolean;
25
+ }
26
+
27
+ export type PendingParentChangeResult =
28
+ | { status: "changed"; generation: number }
29
+ | { status: "cancelled"; generation: number };
30
+
31
+ type PendingChildEntry =
32
+ | { phase: "accepted"; acceptance: PendingChildAcceptance }
33
+ | { phase: "running"; acceptance: PendingChildAcceptance; run: PendingChildRun };
34
+
35
+ interface ParentRunState {
36
+ identity: ParentRunIdentity;
37
+ cancelled: boolean;
38
+ generation: number;
39
+ childrenByToken: Map<string, PendingChildEntry>;
40
+ emptyWaiters: Set<(result: PendingChildrenLatchResult) => void>;
41
+ changeWaiters: Set<(result: PendingParentChangeResult) => void>;
42
+ }
43
+
44
+ export interface PendingChildController {
45
+ acceptChild(parent: ParentRunIdentity, childName: string): PendingChildAcceptance;
46
+ bindAcceptedChild(
47
+ acceptance: PendingChildAcceptance,
48
+ childName: string,
49
+ childRunId: string
50
+ ): PendingChildRun | undefined;
51
+ settleAcceptance(acceptance: PendingChildAcceptance): boolean;
52
+ settleChildRun(child: PendingChildRun): boolean;
53
+ hasPendingChildren(parent: ParentRunIdentity): boolean;
54
+ pendingCount(parent: ParentRunIdentity): number;
55
+ observeParent(parent: ParentRunIdentity): PendingParentSnapshot;
56
+ signalParentChange(parent: ParentRunIdentity): boolean;
57
+ waitForChangeOrCancelled(
58
+ parent: ParentRunIdentity,
59
+ afterGeneration: number
60
+ ): Promise<PendingParentChangeResult>;
61
+ waitForEmptyOrCancelled(parent: ParentRunIdentity): Promise<PendingChildrenLatchResult>;
62
+ cancelParent(parent: ParentRunIdentity): boolean;
63
+ forgetParent(parent: ParentRunIdentity): boolean;
64
+ onParentCancelled(listener: (parent: ParentRunIdentity) => void): () => void;
65
+ trackedParentCount(): number;
66
+ }
67
+
68
+ function parentKey(parent: ParentRunIdentity): string {
69
+ return `${parent.teamName}\u0000${parent.parentName}\u0000${parent.parentRunId}`;
70
+ }
71
+
72
+ function sameParent(left: ParentRunIdentity, right: ParentRunIdentity): boolean {
73
+ return left.teamName === right.teamName
74
+ && left.parentName === right.parentName
75
+ && left.parentRunId === right.parentRunId;
76
+ }
77
+
78
+ function immutableParent(parent: ParentRunIdentity): ParentRunIdentity {
79
+ return Object.freeze({
80
+ teamName: parent.teamName,
81
+ parentName: parent.parentName,
82
+ parentRunId: parent.parentRunId,
83
+ });
84
+ }
85
+
86
+ export function createPendingChildController(): PendingChildController {
87
+ const parents = new Map<string, ParentRunState>();
88
+ const cancellationListeners = new Set<(parent: ParentRunIdentity) => void>();
89
+
90
+ const createParentState = (parent: ParentRunIdentity): ParentRunState => ({
91
+ identity: immutableParent(parent),
92
+ cancelled: false,
93
+ generation: 0,
94
+ childrenByToken: new Map(),
95
+ emptyWaiters: new Set(),
96
+ changeWaiters: new Set(),
97
+ });
98
+
99
+ const ensureParentState = (parent: ParentRunIdentity): ParentRunState => {
100
+ const key = parentKey(parent);
101
+ let state = parents.get(key);
102
+ if (!state) {
103
+ state = createParentState(parent);
104
+ parents.set(key, state);
105
+ }
106
+ return state;
107
+ };
108
+
109
+ const resolveEmptyWaiters = (state: ParentRunState, result: PendingChildrenLatchResult): void => {
110
+ const waiters = Array.from(state.emptyWaiters);
111
+ state.emptyWaiters.clear();
112
+ for (const resolve of waiters) resolve(result);
113
+ };
114
+
115
+ const signalChange = (state: ParentRunState): void => {
116
+ state.generation += 1;
117
+ const result: PendingParentChangeResult = state.cancelled
118
+ ? { status: "cancelled", generation: state.generation }
119
+ : { status: "changed", generation: state.generation };
120
+ const waiters = Array.from(state.changeWaiters);
121
+ state.changeWaiters.clear();
122
+ for (const resolve of waiters) resolve(result);
123
+ };
124
+
125
+ const settleToken = (acceptance: PendingChildAcceptance): boolean => {
126
+ const key = parentKey(acceptance);
127
+ const state = parents.get(key);
128
+ const entry = state?.childrenByToken.get(acceptance.token);
129
+ if (!state || !entry || entry.acceptance !== acceptance) return false;
130
+
131
+ state.childrenByToken.delete(acceptance.token);
132
+ signalChange(state);
133
+ if (state.childrenByToken.size === 0) resolveEmptyWaiters(state, "empty");
134
+ return true;
135
+ };
136
+
137
+ return {
138
+ acceptChild(parent, childName) {
139
+ const key = parentKey(parent);
140
+ let state = parents.get(key);
141
+ if (state?.cancelled) {
142
+ throw new Error(`Cannot accept child ${childName}: parent ${parent.parentName} run ${parent.parentRunId} is closing.`);
143
+ }
144
+ if (!state) state = ensureParentState(parent);
145
+
146
+ const acceptance = Object.freeze({
147
+ ...state.identity,
148
+ token: crypto.randomUUID(),
149
+ childName,
150
+ });
151
+ state.childrenByToken.set(acceptance.token, { phase: "accepted", acceptance });
152
+ signalChange(state);
153
+ return acceptance;
154
+ },
155
+
156
+ bindAcceptedChild(acceptance, childName, childRunId) {
157
+ const state = parents.get(parentKey(acceptance));
158
+ const entry = state?.childrenByToken.get(acceptance.token);
159
+ if (!state || state.cancelled || !entry || entry.phase !== "accepted" || entry.acceptance !== acceptance) {
160
+ return undefined;
161
+ }
162
+ if (acceptance.childName !== childName) return undefined;
163
+
164
+ const run = Object.freeze({
165
+ teamName: acceptance.teamName,
166
+ parentName: acceptance.parentName,
167
+ parentRunId: acceptance.parentRunId,
168
+ childName,
169
+ childRunId,
170
+ });
171
+ state.childrenByToken.set(acceptance.token, { phase: "running", acceptance, run });
172
+ signalChange(state);
173
+ return run;
174
+ },
175
+
176
+ settleAcceptance(acceptance) {
177
+ return settleToken(acceptance);
178
+ },
179
+
180
+ settleChildRun(child) {
181
+ const state = parents.get(parentKey(child));
182
+ if (!state || state.cancelled) return false;
183
+ for (const entry of state.childrenByToken.values()) {
184
+ if (entry.phase !== "running") continue;
185
+ if (entry.run.childName !== child.childName || entry.run.childRunId !== child.childRunId) continue;
186
+ if (!sameParent(entry.run, child)) continue;
187
+ return settleToken(entry.acceptance);
188
+ }
189
+ return false;
190
+ },
191
+
192
+ hasPendingChildren(parent) {
193
+ return (parents.get(parentKey(parent))?.childrenByToken.size ?? 0) > 0;
194
+ },
195
+
196
+ pendingCount(parent) {
197
+ return parents.get(parentKey(parent))?.childrenByToken.size ?? 0;
198
+ },
199
+
200
+ observeParent(parent) {
201
+ const state = ensureParentState(parent);
202
+ return {
203
+ generation: state.generation,
204
+ pendingCount: state.childrenByToken.size,
205
+ cancelled: state.cancelled,
206
+ };
207
+ },
208
+
209
+ signalParentChange(parent) {
210
+ const state = ensureParentState(parent);
211
+ if (state.cancelled) return false;
212
+ signalChange(state);
213
+ return true;
214
+ },
215
+
216
+ waitForChangeOrCancelled(parent, afterGeneration) {
217
+ const state = ensureParentState(parent);
218
+ if (state.cancelled) {
219
+ return Promise.resolve({ status: "cancelled", generation: state.generation });
220
+ }
221
+ if (state.generation !== afterGeneration) {
222
+ return Promise.resolve({ status: "changed", generation: state.generation });
223
+ }
224
+ return new Promise<PendingParentChangeResult>((resolve) => {
225
+ state.changeWaiters.add(resolve);
226
+ });
227
+ },
228
+
229
+ waitForEmptyOrCancelled(parent) {
230
+ const state = parents.get(parentKey(parent));
231
+ if (!state) return Promise.resolve("empty");
232
+ if (state.cancelled) return Promise.resolve("cancelled");
233
+ if (state.childrenByToken.size === 0) return Promise.resolve("empty");
234
+ return new Promise<PendingChildrenLatchResult>((resolve) => {
235
+ state.emptyWaiters.add(resolve);
236
+ });
237
+ },
238
+
239
+ cancelParent(parent) {
240
+ const state = ensureParentState(parent);
241
+ if (state.cancelled) return false;
242
+ state.cancelled = true;
243
+ state.childrenByToken.clear();
244
+ signalChange(state);
245
+ resolveEmptyWaiters(state, "cancelled");
246
+ for (const listener of cancellationListeners) {
247
+ try {
248
+ listener(state.identity);
249
+ } catch {
250
+ // Cancellation is authoritative even if an in-memory observer fails.
251
+ }
252
+ }
253
+ return true;
254
+ },
255
+
256
+ forgetParent(parent) {
257
+ const key = parentKey(parent);
258
+ const state = parents.get(key);
259
+ if (!state || state.childrenByToken.size > 0) return false;
260
+ parents.delete(key);
261
+ resolveEmptyWaiters(state, state.cancelled ? "cancelled" : "empty");
262
+ const changeResult: PendingParentChangeResult = state.cancelled
263
+ ? { status: "cancelled", generation: state.generation }
264
+ : { status: "changed", generation: state.generation };
265
+ const changeWaiters = Array.from(state.changeWaiters);
266
+ state.changeWaiters.clear();
267
+ for (const resolve of changeWaiters) resolve(changeResult);
268
+ return true;
269
+ },
270
+
271
+ onParentCancelled(listener) {
272
+ cancellationListeners.add(listener);
273
+ return () => { cancellationListeners.delete(listener); };
274
+ },
275
+
276
+ trackedParentCount() {
277
+ return parents.size;
278
+ },
279
+ };
280
+ }
@@ -0,0 +1,68 @@
1
+ import type { AgentSession } from "@mariozechner/pi-coding-agent";
2
+ import type { ContextUsageSnapshot, RuntimeError } from "../../src/utils/runtime";
3
+ import type { ManagedReadAgentLifecycleState } from "../agents/read-agent-session-lifecycle";
4
+
5
+ export interface RunningReadAgent extends ManagedReadAgentLifecycleState {
6
+ runId: string;
7
+ name: string;
8
+ teamName: string;
9
+ startedAt: number;
10
+ tokensUsed: number;
11
+ contextUsage?: ContextUsageSnapshot;
12
+ status: "starting" | "thinking" | "working" | "finishing";
13
+ recentEvents: string[];
14
+ lastActivityAt: number;
15
+ activeToolName?: string;
16
+ lastError?: RuntimeError;
17
+ idleNudgeLevel?: "soft" | "hard";
18
+ role?: string;
19
+ model?: string;
20
+ thinking?: string;
21
+ modelSlot?: string;
22
+ latestAssistantSnippet?: string;
23
+ assistantProgressNormalizedTail?: string;
24
+ assistantProgressTailTruncated?: boolean;
25
+ assistantProgressContentIndex?: number;
26
+ assistantProgressNeedsSeparator?: boolean;
27
+ assistantProgressIncrementalUnsafe?: boolean;
28
+ latestProgress?: string;
29
+ progressUpdatedAt?: number;
30
+ /** Remove the private child transcript only after durable successful reporting and session disposal. */
31
+ cleanupPrivateSessionOnFinalize?: boolean;
32
+ /** Fail finalization closed so runtime, member, fence, and transcript remain recoverable. */
33
+ finalizationBlockedReason?: string;
34
+ session?: AgentSession;
35
+ finished?: Promise<void>;
36
+ }
37
+
38
+ export type AgentReportSource =
39
+ | "report_and_exit"
40
+ | "persisted-report_and_exit"
41
+ | "assistant-text"
42
+ | "persisted-assistant-text"
43
+ | "irrecoverable-empty"
44
+ | "runtime-failure";
45
+
46
+ export interface CompletedAgentReport {
47
+ name: string;
48
+ role: string;
49
+ status: "completed" | "failed";
50
+ report: string;
51
+ summary?: string;
52
+ completedAt: number;
53
+ startedAt?: number;
54
+ elapsedMs?: number;
55
+ tokensUsed?: number;
56
+ costUsd?: number;
57
+ model?: string;
58
+ thinking?: string;
59
+ modelSlot?: string;
60
+ color?: string;
61
+ requestedBy?: string;
62
+ initialPrompt?: string;
63
+ reportSource?: AgentReportSource;
64
+ recoveryAttempted?: boolean;
65
+ recoverySessionId?: string;
66
+ recoverySessionFile?: string;
67
+ source: "read-agent" | "lead-inbox" | "report-event";
68
+ }
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ const contractPath = path.resolve(process.cwd(), "extensions/team/team-contracts.json");
6
+
7
+ describe("persisted team behavior contracts", () => {
8
+ it("saves Leo's required team lifecycle/UI contracts locally", () => {
9
+ expect(fs.existsSync(contractPath)).toBe(true);
10
+ const contract = JSON.parse(fs.readFileSync(contractPath, "utf-8"));
11
+
12
+ expect(contract.version).toBe(1);
13
+ expect(contract.contracts).toEqual(expect.arrayContaining([
14
+ expect.objectContaining({ id: "ui.team-activity-card" }),
15
+ expect.objectContaining({ id: "ui.active-reader-writer-info" }),
16
+ expect.objectContaining({ id: "ui.agent-follow-navigation" }),
17
+ expect.objectContaining({ id: "helpers.requested-by-writer" }),
18
+ expect.objectContaining({ id: "writers.request-only-lead-spawns" }),
19
+ expect.objectContaining({ id: "messaging.push-on-completion" }),
20
+ expect.objectContaining({ id: "completion.remove-from-active-keep-history" }),
21
+ expect.objectContaining({ id: "runtime.reload-disposes-background-handles" }),
22
+ expect.objectContaining({ id: "writers.tmux-closes-when-done" }),
23
+ ]));
24
+
25
+ const byId = Object.fromEntries(contract.contracts.map((entry: any) => [entry.id, entry.description]));
26
+ expect(byId["ui.team-activity-card"]).not.toContain("writers are tmux agents and are not mirrored");
27
+ expect(byId["ui.team-activity-card"]).not.toContain("lead inbox reports");
28
+ expect(byId["ui.team-activity-card"]).toContain("reports arrive as messages");
29
+ expect(byId["ui.team-activity-card"]).toContain("stable below-editor pi-emote-style card");
30
+ expect(byId["ui.team-activity-card"]).toContain("updates progress, elapsed time, and token counts in place without flicker");
31
+ expect(byId["ui.team-activity-card"]).toContain("revealing the new phrase left-to-right");
32
+ expect(byId["ui.team-activity-card"]).toContain("Each active writer row shows +N");
33
+ expect(byId["ui.team-activity-card"]).toContain("without changing global active/read/write counts");
34
+ expect(byId["ui.active-reader-writer-info"]).toContain("team activity card");
35
+ expect(byId["ui.active-reader-writer-info"]).toContain("Active reader and writer information");
36
+ expect(byId["ui.agent-follow-navigation"]).toContain("full-window interactive live-follow view");
37
+ expect(byId["ui.agent-follow-navigation"]).toContain("Up moves to the previous agent");
38
+ expect(byId["ui.agent-follow-navigation"]).toContain("pairs tool calls with their results in readable blocks");
39
+ expect(byId["ui.agent-follow-navigation"]).toContain("provides an input for direct messages");
40
+ expect(byId["ui.agent-follow-navigation"]).toContain("Pressing x stops the selected subagent");
41
+ expect(byId["writers.request-only-lead-spawns"]).toContain("explicitly opted-in depth-0 write-feature/write-critical");
42
+ expect(byId["writers.request-only-lead-spawns"]).toContain("any canonical read tier and any count subject to global capacity");
43
+ expect(byId["writers.request-only-lead-spawns"]).toContain("Read agents, depth-1 children, write-patch, and write-system remain denied");
44
+ expect(byId["writers.request-only-lead-spawns"]).toContain("requesting writer and cannot delegate");
45
+ expect(byId["messaging.push-on-completion"]).toContain("classified completion notice to the lead");
46
+ expect(byId["runtime.reload-disposes-background-handles"]).toContain("title/initial-trigger timers");
47
+ expect(byId["runtime.reload-disposes-background-handles"]).toContain("active in-process agent sessions/heartbeats");
48
+ });
49
+
50
+ it("requires context-rich missions for isolated agent sessions", () => {
51
+ const skill = fs.readFileSync(path.resolve(process.cwd(), "skills/teams.md"), "utf-8");
52
+
53
+ expect(skill).toContain("they do not inherit the lead or parent conversation");
54
+ expect(skill).toContain("## Context handoff contract");
55
+ expect(skill).toContain("make a context-selection pass");
56
+ expect(skill).toContain("### Lazy session reference");
57
+ expect(skill).toContain("session_context: \"lazy\"");
58
+ expect(skill).toContain("Do not enable it by default or use it as an excuse for a weak mission");
59
+ expect(skill).toContain("The child should not open it by default");
60
+ expect(skill).toContain("excludes thinking, images, raw tool arguments, and raw tool-result bodies");
61
+ expect(skill).toContain("**Augment/reuse (default):**");
62
+ expect(skill).toContain("**Corroborate:**");
63
+ expect(skill).toContain("**Blind re-derive (exception):**");
64
+ for (const field of [
65
+ "**Question:**",
66
+ "**Expected delta:**",
67
+ "**Known:**",
68
+ "**Inspected:**",
69
+ "**Do not rediscover:**",
70
+ "**Dependencies consumed:**",
71
+ ]) {
72
+ expect(skill).toContain(field);
73
+ }
74
+ for (const label of ["`[verified]`", "`[reported]`", "`[hypothesis]`", "`[open]`", "`[conflict]`", "`[decision]`"]) {
75
+ expect(skill).toContain(label);
76
+ }
77
+ expect(skill).toContain("an **Evidence delta**");
78
+ expect(skill).toContain("what the next lane must not repeat plus its next bounded question");
79
+ expect(skill).toContain("Give each child the same Context handoff contract");
80
+
81
+ const exampleSlice = (name: string, endMarker: string) => {
82
+ const start = skill.indexOf(`name: "${name}"`);
83
+ const end = skill.indexOf(endMarker, start);
84
+ expect(start, `${name} example start`).toBeGreaterThan(-1);
85
+ expect(end, `${name} example end`).toBeGreaterThan(start);
86
+ return skill.slice(start, end);
87
+ };
88
+ const writerExamples = [
89
+ exampleSlice("docs-fix", "For a bounded feature or critical writer"),
90
+ exampleSlice("parser-feature", "## Hot-word trigger"),
91
+ ];
92
+ for (const example of writerExamples) {
93
+ expect(example).toContain("Mode: EDIT-ALLOWED");
94
+ for (const field of ["Question:", "Expected delta:", "Known:", "Inspected:", "Do not rediscover:", "Dependencies consumed:"]) {
95
+ expect(example).toContain(field);
96
+ }
97
+ }
98
+ expect(writerExamples[1]).toContain("same Context handoff contract");
99
+
100
+ const knownExampleLines = skill.split("\n").filter((line) => line.startsWith("Known:"));
101
+ expect(knownExampleLines.length).toBeGreaterThanOrEqual(5);
102
+ for (const line of knownExampleLines) {
103
+ const claims = line.match(/\[(?:verified|reported|decision)\]/g) ?? [];
104
+ const sources = line.match(/source:/g) ?? [];
105
+ expect(claims.length, line).toBeGreaterThan(0);
106
+ expect(sources.length, line).toBe(claims.length);
107
+ }
108
+ expect(skill).toMatch(/Prior result: \[verified\] source:/);
109
+ expect(skill).toContain("replace illustrative placeholders with real evidence before using the `docs-fix` template");
110
+ });
111
+ });