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.
- package/README.md +105 -0
- package/assets/pi-extended-teams-agent-navigation.png +0 -0
- package/assets/pi-extended-teams-in-action.png +0 -0
- package/extensions/agents/read-agent-report.ts +181 -0
- package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
- package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
- package/extensions/agents/read-agent.test.ts +3077 -0
- package/extensions/agents/read-agent.ts +1119 -0
- package/extensions/agents/write-agent.test.ts +513 -0
- package/extensions/agents/write-agent.ts +392 -0
- package/extensions/events/register-events.test.ts +465 -0
- package/extensions/events/register-events.ts +409 -0
- package/extensions/index.test.ts +1659 -0
- package/extensions/index.ts +1228 -0
- package/extensions/internal/agent-session-files.test.ts +164 -0
- package/extensions/internal/agent-session-files.ts +320 -0
- package/extensions/internal/debug.ts +44 -0
- package/extensions/internal/model-selection.ts +82 -0
- package/extensions/internal/pi-command.test.ts +191 -0
- package/extensions/internal/pi-command.ts +224 -0
- package/extensions/internal/pi-runtime-api.test.ts +43 -0
- package/extensions/internal/pi-runtime-api.ts +78 -0
- package/extensions/internal/schema.ts +19 -0
- package/extensions/internal/session-context-reference.test.ts +290 -0
- package/extensions/internal/session-context-reference.ts +430 -0
- package/extensions/internal/session-files.test.ts +225 -0
- package/extensions/internal/session-files.ts +273 -0
- package/extensions/internal/session-usage.ts +59 -0
- package/extensions/resources/spawn-resource-plan.test.ts +233 -0
- package/extensions/resources/spawn-resource-plan.ts +247 -0
- package/extensions/runtime/active-agent-sleep.test.ts +157 -0
- package/extensions/runtime/active-agent-sleep.ts +117 -0
- package/extensions/runtime/nested-read-agents.ts +22 -0
- package/extensions/runtime/pending-child-controller.test.ts +169 -0
- package/extensions/runtime/pending-child-controller.ts +280 -0
- package/extensions/runtime/types.ts +68 -0
- package/extensions/team/contracts.test.ts +111 -0
- package/extensions/team/lifecycle.test.ts +1095 -0
- package/extensions/team/lifecycle.ts +502 -0
- package/extensions/team/recipient-closure.test.ts +97 -0
- package/extensions/team/recipient-closure.ts +120 -0
- package/extensions/team/roster.test.ts +129 -0
- package/extensions/team/roster.ts +165 -0
- package/extensions/team/team-contracts.json +43 -0
- package/extensions/team/writer-screens.test.ts +50 -0
- package/extensions/team/writer-screens.ts +156 -0
- package/extensions/tools/agent-communication-tools.test.ts +306 -0
- package/extensions/tools/agent-communication-tools.ts +183 -0
- package/extensions/tools/coordination-tools.test.ts +670 -0
- package/extensions/tools/coordination-tools.ts +304 -0
- package/extensions/tools/delegation-guard.test.ts +95 -0
- package/extensions/tools/delegation-guard.ts +65 -0
- package/extensions/tools/file-claim-tools.test.ts +104 -0
- package/extensions/tools/file-claim-tools.ts +68 -0
- package/extensions/tools/model-tools.ts +53 -0
- package/extensions/tools/predefined-tools.test.ts +647 -0
- package/extensions/tools/predefined-tools.ts +331 -0
- package/extensions/tools/read-helper.test.ts +35 -0
- package/extensions/tools/task-runtime-tools.test.ts +334 -0
- package/extensions/tools/task-runtime-tools.ts +227 -0
- package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
- package/extensions/tools/team-tools.ts +1270 -0
- package/extensions/ui/agent-follow-view.test.ts +520 -0
- package/extensions/ui/agent-follow-view.ts +779 -0
- package/extensions/ui/agent-navigation.test.ts +52 -0
- package/extensions/ui/agent-navigation.ts +55 -0
- package/extensions/ui/ansi.ts +16 -0
- package/extensions/ui/extensions-command.test.ts +336 -0
- package/extensions/ui/extensions-command.ts +282 -0
- package/extensions/ui/favorite-models-command.test.ts +280 -0
- package/extensions/ui/favorite-models-command.ts +423 -0
- package/extensions/ui/frame.ts +77 -0
- package/extensions/ui/input.ts +17 -0
- package/extensions/ui/read-agent-status.test.ts +117 -0
- package/extensions/ui/read-agent-status.ts +125 -0
- package/extensions/ui/renderers.ts +243 -0
- package/extensions/ui/status-widget.test.ts +143 -0
- package/extensions/ui/status-widget.ts +440 -0
- package/extensions/ui-frame.test.ts +69 -0
- package/package.json +75 -0
- package/skills/teams.md +256 -0
- package/src/adapters/terminal-registry.ts +78 -0
- package/src/adapters/tmux-adapter.test.ts +276 -0
- package/src/adapters/tmux-adapter.ts +200 -0
- package/src/orchestration/index.ts +437 -0
- package/src/orchestration/orchestrator.test.ts +396 -0
- package/src/orchestration/types.ts +125 -0
- package/src/utils/atomic-json.ts +21 -0
- package/src/utils/claims.test.ts +137 -0
- package/src/utils/claims.ts +169 -0
- package/src/utils/hooks.test.ts +75 -0
- package/src/utils/hooks.ts +35 -0
- package/src/utils/lifecycle-tombstone.test.ts +105 -0
- package/src/utils/lifecycle-tombstone.ts +268 -0
- package/src/utils/lock.race.child.ts +44 -0
- package/src/utils/lock.race.test.ts +198 -0
- package/src/utils/lock.test.ts +90 -0
- package/src/utils/lock.ts +186 -0
- package/src/utils/messaging.test.ts +337 -0
- package/src/utils/messaging.ts +441 -0
- package/src/utils/model-resolution.test.ts +231 -0
- package/src/utils/model-resolution.ts +322 -0
- package/src/utils/models.test.ts +8 -0
- package/src/utils/models.ts +115 -0
- package/src/utils/paths.ts +81 -0
- package/src/utils/predefined-teams/types.ts +48 -0
- package/src/utils/predefined-teams.save-template.test.ts +74 -0
- package/src/utils/predefined-teams.test.ts +441 -0
- package/src/utils/predefined-teams.ts +471 -0
- package/src/utils/read-helper-queue.ts +99 -0
- package/src/utils/report-events.test.ts +154 -0
- package/src/utils/report-events.ts +222 -0
- package/src/utils/runtime.test.ts +314 -0
- package/src/utils/runtime.ts +261 -0
- package/src/utils/security.test.ts +43 -0
- package/src/utils/settings.test.ts +480 -0
- package/src/utils/settings.ts +645 -0
- package/src/utils/shared-memory.test.ts +80 -0
- package/src/utils/shared-memory.ts +81 -0
- package/src/utils/tasks.race.test.ts +44 -0
- package/src/utils/tasks.test.ts +229 -0
- package/src/utils/tasks.ts +396 -0
- package/src/utils/teams.ts +231 -0
- package/src/utils/terminal-adapter.ts +103 -0
- package/src/utils/thinking-levels.test.ts +56 -0
- package/src/utils/thinking-levels.ts +47 -0
- package/src/utils/workflow-metadata.test.ts +11 -0
- package/src/utils/workflow-metadata.ts +88 -0
- package/src/utils/write-queue.test.ts +251 -0
- package/src/utils/write-queue.ts +202 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadSettings, type PiExtendedTeamsSettings } from "../../src/utils/settings";
|
|
4
|
+
|
|
5
|
+
const SELF_COMMAND_NAME = "agents-extensions";
|
|
6
|
+
export const EXTENSIONS_COMMAND_DESCRIPTION = "Select which observable loaded Pi extensions spawned agents receive.";
|
|
7
|
+
const GENERIC_TOOL_SOURCES = new Set(["builtin", "sdk"]);
|
|
8
|
+
|
|
9
|
+
type ResourceScope = "user" | "project" | "temporary";
|
|
10
|
+
|
|
11
|
+
export interface ExtensionSourceInfo {
|
|
12
|
+
path: string;
|
|
13
|
+
source: string;
|
|
14
|
+
scope: ResourceScope;
|
|
15
|
+
origin: "package" | "top-level";
|
|
16
|
+
baseDir?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LeadExtensionSnapshot {
|
|
20
|
+
/** Stable, human-readable selector when unique in the current lead snapshot. */
|
|
21
|
+
name: string;
|
|
22
|
+
/** Canonical filesystem identity used for deterministic deduplication. */
|
|
23
|
+
identity: string;
|
|
24
|
+
/** Pi's registered extension entrypoint path, passed to the child unchanged. */
|
|
25
|
+
path: string;
|
|
26
|
+
sourceInfo: ExtensionSourceInfo;
|
|
27
|
+
isSelf: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type SpawnExtensionState = "selected" | "available" | "blocked" | "self";
|
|
31
|
+
|
|
32
|
+
export interface SpawnExtensionCandidate extends LeadExtensionSnapshot {
|
|
33
|
+
selector: string;
|
|
34
|
+
selected: boolean;
|
|
35
|
+
state: SpawnExtensionState;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type SpawnResourceDiagnosticCode =
|
|
39
|
+
| "stale-selection"
|
|
40
|
+
| "blocked-selection"
|
|
41
|
+
| "self-excluded";
|
|
42
|
+
|
|
43
|
+
export interface SpawnResourceDiagnostic {
|
|
44
|
+
code: SpawnResourceDiagnosticCode;
|
|
45
|
+
message: string;
|
|
46
|
+
configuredEntry?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SpawnResourcePlan {
|
|
50
|
+
selectionMode: "default" | "explicit";
|
|
51
|
+
extensionPaths: readonly string[];
|
|
52
|
+
selfExtensionPath?: string;
|
|
53
|
+
extensions: readonly SpawnExtensionCandidate[];
|
|
54
|
+
diagnostics: readonly SpawnResourceDiagnostic[];
|
|
55
|
+
skills: "all";
|
|
56
|
+
trust: Readonly<{
|
|
57
|
+
cwd: string;
|
|
58
|
+
projectTrusted: boolean;
|
|
59
|
+
}>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface LeadRegistrationApi {
|
|
63
|
+
getCommands?(): Array<{
|
|
64
|
+
name?: string;
|
|
65
|
+
description?: string;
|
|
66
|
+
source?: string;
|
|
67
|
+
sourceInfo?: ExtensionSourceInfo;
|
|
68
|
+
}>;
|
|
69
|
+
getAllTools?(): Array<{
|
|
70
|
+
name?: string;
|
|
71
|
+
sourceInfo?: ExtensionSourceInfo;
|
|
72
|
+
}>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface CreateSpawnResourcePlanOptions {
|
|
76
|
+
cwd: string;
|
|
77
|
+
projectTrusted: boolean;
|
|
78
|
+
settings?: PiExtendedTeamsSettings;
|
|
79
|
+
pi?: LeadRegistrationApi;
|
|
80
|
+
leadExtensions?: readonly LeadExtensionSnapshot[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function canonicalPath(filePath: string): string {
|
|
84
|
+
const absolute = path.resolve(filePath);
|
|
85
|
+
try {
|
|
86
|
+
return fs.realpathSync(absolute);
|
|
87
|
+
} catch {
|
|
88
|
+
return absolute;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function extensionName(filePath: string): string {
|
|
93
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
94
|
+
const base = normalized.split("/").pop() || normalized;
|
|
95
|
+
if (/^index\.(?:[cm]?[jt]s)$/.test(base)) {
|
|
96
|
+
const segments = normalized.split("/");
|
|
97
|
+
const parent = segments.at(-2) || base;
|
|
98
|
+
return parent === "extensions" ? segments.at(-3) || parent : parent;
|
|
99
|
+
}
|
|
100
|
+
return base.replace(/\.(?:[cm]?[jt]s)$/, "") || base;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function usableSourceInfo(sourceInfo: ExtensionSourceInfo | undefined): sourceInfo is ExtensionSourceInfo {
|
|
104
|
+
return !!sourceInfo
|
|
105
|
+
&& typeof sourceInfo.path === "string"
|
|
106
|
+
&& sourceInfo.path.length > 0
|
|
107
|
+
&& !sourceInfo.path.startsWith("<");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Snapshot extensions observable through Pi's public registration APIs.
|
|
112
|
+
*
|
|
113
|
+
* Pi currently exposes sourceInfo for commands and tools, not for event-only,
|
|
114
|
+
* renderer-only, shortcut-only, or provider-only extensions. Those extensions
|
|
115
|
+
* cannot be observed or propagated without using private loader internals.
|
|
116
|
+
*/
|
|
117
|
+
export function snapshotLeadExtensions(pi: LeadRegistrationApi): readonly LeadExtensionSnapshot[] {
|
|
118
|
+
const commands = typeof pi.getCommands === "function" ? pi.getCommands() : [];
|
|
119
|
+
const tools = typeof pi.getAllTools === "function" ? pi.getAllTools() : [];
|
|
120
|
+
const selfPaths = new Set(commands
|
|
121
|
+
.filter((command) => command.source === "extension"
|
|
122
|
+
&& (command.description === EXTENSIONS_COMMAND_DESCRIPTION
|
|
123
|
+
|| (command.description === undefined && command.name === SELF_COMMAND_NAME)))
|
|
124
|
+
.map((command) => command.sourceInfo)
|
|
125
|
+
.filter(usableSourceInfo)
|
|
126
|
+
.map((sourceInfo) => canonicalPath(sourceInfo.path)));
|
|
127
|
+
|
|
128
|
+
const registrations = [
|
|
129
|
+
...commands
|
|
130
|
+
.filter((command) => command.source === "extension")
|
|
131
|
+
.map((command) => command.sourceInfo),
|
|
132
|
+
...tools
|
|
133
|
+
.filter((tool) => !GENERIC_TOOL_SOURCES.has(tool.sourceInfo?.source ?? ""))
|
|
134
|
+
.map((tool) => tool.sourceInfo),
|
|
135
|
+
].filter(usableSourceInfo);
|
|
136
|
+
|
|
137
|
+
const seen = new Set<string>();
|
|
138
|
+
const snapshot: LeadExtensionSnapshot[] = [];
|
|
139
|
+
for (const sourceInfo of registrations) {
|
|
140
|
+
const identity = canonicalPath(sourceInfo.path);
|
|
141
|
+
if (seen.has(identity)) continue;
|
|
142
|
+
seen.add(identity);
|
|
143
|
+
snapshot.push(Object.freeze({
|
|
144
|
+
name: extensionName(sourceInfo.path),
|
|
145
|
+
identity,
|
|
146
|
+
path: sourceInfo.path,
|
|
147
|
+
sourceInfo: Object.freeze({ ...sourceInfo }),
|
|
148
|
+
isSelf: selfPaths.has(identity),
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
return Object.freeze(snapshot);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Only propagate trust to the same cwd whose trust the parent context resolved. */
|
|
155
|
+
export function parentProjectTrustForSpawn(ctx: any, spawnCwd: string): boolean {
|
|
156
|
+
if (!ctx || typeof ctx.isProjectTrusted !== "function" || typeof ctx.cwd !== "string") return false;
|
|
157
|
+
if (path.resolve(ctx.cwd) !== path.resolve(spawnCwd)) return false;
|
|
158
|
+
return ctx.isProjectTrusted() === true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function normalized(value: string): string {
|
|
162
|
+
return value.replace(/\\/g, "/");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function entryMatchesExtension(entry: string, extension: LeadExtensionSnapshot, cwd: string): boolean {
|
|
166
|
+
if (entry === extension.name || normalized(entry) === normalized(extension.path)) return true;
|
|
167
|
+
if (!path.isAbsolute(entry) && !entry.startsWith(".")) return false;
|
|
168
|
+
return canonicalPath(path.resolve(cwd, entry)) === extension.identity;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function deepFreezePlan(plan: SpawnResourcePlan): SpawnResourcePlan {
|
|
172
|
+
for (const extension of plan.extensions) Object.freeze(extension);
|
|
173
|
+
for (const diagnostic of plan.diagnostics) Object.freeze(diagnostic);
|
|
174
|
+
Object.freeze(plan.extensionPaths);
|
|
175
|
+
Object.freeze(plan.extensions);
|
|
176
|
+
Object.freeze(plan.diagnostics);
|
|
177
|
+
Object.freeze(plan.trust);
|
|
178
|
+
return Object.freeze(plan);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Apply spawned-agent allow/block policy to an immutable lead-session snapshot. */
|
|
182
|
+
export function createSpawnResourcePlan(options: CreateSpawnResourcePlanOptions): SpawnResourcePlan {
|
|
183
|
+
const cwd = path.resolve(options.cwd);
|
|
184
|
+
const settings = options.settings ?? loadSettings({
|
|
185
|
+
projectDir: options.projectTrusted ? cwd : undefined,
|
|
186
|
+
});
|
|
187
|
+
const snapshot = options.leadExtensions ?? snapshotLeadExtensions(options.pi ?? {});
|
|
188
|
+
const allow = settings.extensions.allow;
|
|
189
|
+
const blocked = settings.extensions.block;
|
|
190
|
+
const nameCounts = new Map<string, number>();
|
|
191
|
+
for (const extension of snapshot) nameCounts.set(extension.name, (nameCounts.get(extension.name) ?? 0) + 1);
|
|
192
|
+
|
|
193
|
+
const records = snapshot.map((extension) => {
|
|
194
|
+
const isBlocked = blocked.some((entry) => entryMatchesExtension(entry, extension, cwd));
|
|
195
|
+
const explicitlyAllowed = allow?.some((entry) => entryMatchesExtension(entry, extension, cwd)) ?? false;
|
|
196
|
+
const selected = !extension.isSelf && !isBlocked && (allow === null || explicitlyAllowed);
|
|
197
|
+
const state: SpawnExtensionState = extension.isSelf
|
|
198
|
+
? "self"
|
|
199
|
+
: isBlocked
|
|
200
|
+
? "blocked"
|
|
201
|
+
: selected
|
|
202
|
+
? "selected"
|
|
203
|
+
: "available";
|
|
204
|
+
return Object.freeze({
|
|
205
|
+
...extension,
|
|
206
|
+
selector: nameCounts.get(extension.name) === 1 ? extension.name : extension.path,
|
|
207
|
+
selected,
|
|
208
|
+
state,
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const diagnostics: SpawnResourceDiagnostic[] = [];
|
|
213
|
+
if (allow !== null) {
|
|
214
|
+
for (const configuredEntry of allow) {
|
|
215
|
+
const matches = records.filter((record) => entryMatchesExtension(configuredEntry, record, cwd));
|
|
216
|
+
if (matches.length === 0) {
|
|
217
|
+
diagnostics.push({
|
|
218
|
+
code: "stale-selection",
|
|
219
|
+
configuredEntry,
|
|
220
|
+
message: `Configured extension is not observable in the lead Pi session: ${configuredEntry}`,
|
|
221
|
+
});
|
|
222
|
+
} else if (matches.every((record) => record.isSelf)) {
|
|
223
|
+
diagnostics.push({
|
|
224
|
+
code: "self-excluded",
|
|
225
|
+
configuredEntry,
|
|
226
|
+
message: `pi-extended-teams is injected by identity and is not an external selection: ${configuredEntry}`,
|
|
227
|
+
});
|
|
228
|
+
} else if (matches.every((record) => record.state === "blocked")) {
|
|
229
|
+
diagnostics.push({
|
|
230
|
+
code: "blocked-selection",
|
|
231
|
+
configuredEntry,
|
|
232
|
+
message: `Configured extension is blocked for spawned agents: ${configuredEntry}`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return deepFreezePlan({
|
|
239
|
+
selectionMode: allow === null ? "default" : "explicit",
|
|
240
|
+
extensionPaths: records.filter((extension) => extension.selected).map((extension) => extension.path),
|
|
241
|
+
selfExtensionPath: records.find((extension) => extension.isSelf)?.path,
|
|
242
|
+
extensions: records,
|
|
243
|
+
diagnostics,
|
|
244
|
+
skills: "all",
|
|
245
|
+
trust: { cwd, projectTrusted: options.projectTrusted },
|
|
246
|
+
});
|
|
247
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createActiveAgentSleepController,
|
|
4
|
+
runWithActiveAgentSleepAssertion,
|
|
5
|
+
} from "./active-agent-sleep.js";
|
|
6
|
+
|
|
7
|
+
function deferred<T>() {
|
|
8
|
+
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
9
|
+
let reject!: (reason?: unknown) => void;
|
|
10
|
+
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
|
11
|
+
resolve = resolvePromise;
|
|
12
|
+
reject = rejectPromise;
|
|
13
|
+
});
|
|
14
|
+
return { promise, resolve, reject };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fakeChild() {
|
|
18
|
+
const listeners = new Map<string, (...args: unknown[]) => void>();
|
|
19
|
+
const child = {
|
|
20
|
+
kill: vi.fn(() => true),
|
|
21
|
+
unref: vi.fn(),
|
|
22
|
+
once: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
|
|
23
|
+
listeners.set(event, listener);
|
|
24
|
+
return child;
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
child,
|
|
29
|
+
emit(event: string, ...args: unknown[]) {
|
|
30
|
+
listeners.get(event)?.(...args);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function darwinController() {
|
|
36
|
+
const process = fakeChild();
|
|
37
|
+
const spawnProcess = vi.fn(() => process.child);
|
|
38
|
+
const controller = createActiveAgentSleepController({
|
|
39
|
+
platform: "darwin",
|
|
40
|
+
pid: 4242,
|
|
41
|
+
executable: "/usr/bin/caffeinate",
|
|
42
|
+
executableExists: vi.fn(() => true),
|
|
43
|
+
spawnProcess,
|
|
44
|
+
});
|
|
45
|
+
return { controller, process, spawnProcess };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("active agent sleep controller", () => {
|
|
49
|
+
it("shares one idle-sleep assertion until the final concurrent agent releases it", () => {
|
|
50
|
+
const { controller, process, spawnProcess } = darwinController();
|
|
51
|
+
|
|
52
|
+
const releaseFirst = controller.retain();
|
|
53
|
+
const releaseSecond = controller.retain();
|
|
54
|
+
|
|
55
|
+
expect(spawnProcess).toHaveBeenCalledOnce();
|
|
56
|
+
expect(spawnProcess).toHaveBeenCalledWith(
|
|
57
|
+
"/usr/bin/caffeinate",
|
|
58
|
+
["-i", "-w", "4242"],
|
|
59
|
+
{ stdio: "ignore" },
|
|
60
|
+
);
|
|
61
|
+
expect(process.child.unref).toHaveBeenCalledOnce();
|
|
62
|
+
|
|
63
|
+
releaseFirst();
|
|
64
|
+
expect(process.child.kill).not.toHaveBeenCalled();
|
|
65
|
+
|
|
66
|
+
releaseSecond();
|
|
67
|
+
releaseSecond();
|
|
68
|
+
expect(process.child.kill).toHaveBeenCalledOnce();
|
|
69
|
+
expect(process.child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it.each([
|
|
73
|
+
{ name: "non-macOS", platform: "linux", executableExists: true },
|
|
74
|
+
{ name: "missing caffeinate", platform: "darwin", executableExists: false },
|
|
75
|
+
])("is a safe no-op on $name", ({ platform, executableExists }) => {
|
|
76
|
+
const spawnProcess = vi.fn();
|
|
77
|
+
const controller = createActiveAgentSleepController({
|
|
78
|
+
platform,
|
|
79
|
+
pid: 4242,
|
|
80
|
+
executable: "/usr/bin/caffeinate",
|
|
81
|
+
executableExists: vi.fn(() => executableExists),
|
|
82
|
+
spawnProcess,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const release = controller.retain();
|
|
86
|
+
expect(spawnProcess).not.toHaveBeenCalled();
|
|
87
|
+
expect(() => release()).not.toThrow();
|
|
88
|
+
expect(() => controller.dispose()).not.toThrow();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("falls back without failing the agent when caffeinate cannot be spawned", () => {
|
|
92
|
+
const spawnProcess = vi.fn(() => {
|
|
93
|
+
throw new Error("spawn failed");
|
|
94
|
+
});
|
|
95
|
+
const controller = createActiveAgentSleepController({
|
|
96
|
+
platform: "darwin",
|
|
97
|
+
pid: 4242,
|
|
98
|
+
executable: "/usr/bin/caffeinate",
|
|
99
|
+
executableExists: vi.fn(() => true),
|
|
100
|
+
spawnProcess,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const release = controller.retain();
|
|
104
|
+
expect(spawnProcess).toHaveBeenCalledOnce();
|
|
105
|
+
expect(() => release()).not.toThrow();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("keeps the shared assertion until all wrapped agent runs settle", async () => {
|
|
109
|
+
const { controller, process, spawnProcess } = darwinController();
|
|
110
|
+
const first = deferred<void>();
|
|
111
|
+
const second = deferred<void>();
|
|
112
|
+
|
|
113
|
+
const firstRun = runWithActiveAgentSleepAssertion(controller, () => first.promise);
|
|
114
|
+
const secondRun = runWithActiveAgentSleepAssertion(controller, () => second.promise);
|
|
115
|
+
|
|
116
|
+
expect(spawnProcess).toHaveBeenCalledOnce();
|
|
117
|
+
first.resolve();
|
|
118
|
+
await firstRun;
|
|
119
|
+
expect(process.child.kill).not.toHaveBeenCalled();
|
|
120
|
+
|
|
121
|
+
second.resolve();
|
|
122
|
+
await secondRun;
|
|
123
|
+
expect(process.child.kill).toHaveBeenCalledOnce();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("releases the assertion after synchronous launch failure and async rejection", async () => {
|
|
127
|
+
const releaseSync = vi.fn();
|
|
128
|
+
const releaseAsync = vi.fn();
|
|
129
|
+
const controller = {
|
|
130
|
+
retain: vi.fn()
|
|
131
|
+
.mockReturnValueOnce(releaseSync)
|
|
132
|
+
.mockReturnValueOnce(releaseAsync),
|
|
133
|
+
dispose: vi.fn(),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
expect(() => runWithActiveAgentSleepAssertion(controller, () => {
|
|
137
|
+
throw new Error("sync failure");
|
|
138
|
+
})).toThrow("sync failure");
|
|
139
|
+
expect(releaseSync).toHaveBeenCalledOnce();
|
|
140
|
+
|
|
141
|
+
await expect(runWithActiveAgentSleepAssertion(controller, async () => {
|
|
142
|
+
throw new Error("async failure");
|
|
143
|
+
})).rejects.toThrow("async failure");
|
|
144
|
+
expect(releaseAsync).toHaveBeenCalledOnce();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("force-releases the assertion during extension disposal", () => {
|
|
148
|
+
const { controller, process } = darwinController();
|
|
149
|
+
const release = controller.retain();
|
|
150
|
+
|
|
151
|
+
controller.dispose();
|
|
152
|
+
release();
|
|
153
|
+
|
|
154
|
+
expect(process.child.kill).toHaveBeenCalledOnce();
|
|
155
|
+
expect(process.child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
const MACOS_CAFFEINATE = "/usr/bin/caffeinate";
|
|
5
|
+
const NOOP_RELEASE = () => {};
|
|
6
|
+
|
|
7
|
+
export interface SleepAssertionProcess {
|
|
8
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
9
|
+
unref(): void;
|
|
10
|
+
once(event: string, listener: (...args: any[]) => void): unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ActiveAgentSleepController {
|
|
14
|
+
retain(): () => void;
|
|
15
|
+
dispose(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ActiveAgentSleepControllerOptions {
|
|
19
|
+
platform?: string;
|
|
20
|
+
pid?: number;
|
|
21
|
+
executable?: string;
|
|
22
|
+
executableExists?(path: string): boolean;
|
|
23
|
+
spawnProcess?(
|
|
24
|
+
command: string,
|
|
25
|
+
args: string[],
|
|
26
|
+
options: { stdio: "ignore" },
|
|
27
|
+
): SleepAssertionProcess;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createActiveAgentSleepController(
|
|
31
|
+
options: ActiveAgentSleepControllerOptions = {},
|
|
32
|
+
): ActiveAgentSleepController {
|
|
33
|
+
const platform = options.platform ?? process.platform;
|
|
34
|
+
const pid = options.pid ?? process.pid;
|
|
35
|
+
const executable = options.executable ?? MACOS_CAFFEINATE;
|
|
36
|
+
const executableExists = options.executableExists ?? existsSync;
|
|
37
|
+
const spawnProcess = options.spawnProcess ?? ((command, args, spawnOptions) => {
|
|
38
|
+
return spawn(command, args, spawnOptions);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
let activeCount = 0;
|
|
42
|
+
let disposed = false;
|
|
43
|
+
let assertionProcess: SleepAssertionProcess | null = null;
|
|
44
|
+
|
|
45
|
+
const stopAssertion = () => {
|
|
46
|
+
const child = assertionProcess;
|
|
47
|
+
assertionProcess = null;
|
|
48
|
+
if (!child) return;
|
|
49
|
+
try {
|
|
50
|
+
child.kill("SIGTERM");
|
|
51
|
+
} catch {
|
|
52
|
+
// The assertion may already have exited. Releasing remains idempotent.
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const startAssertion = () => {
|
|
57
|
+
if (platform !== "darwin" || !executableExists(executable) || assertionProcess) return;
|
|
58
|
+
|
|
59
|
+
let child: SleepAssertionProcess | null = null;
|
|
60
|
+
try {
|
|
61
|
+
// -i prevents only idle system sleep. Omitting -d/-u intentionally allows
|
|
62
|
+
// the display to dim and sleep. -w also releases if the lead Pi exits.
|
|
63
|
+
child = spawnProcess(executable, ["-i", "-w", String(pid)], { stdio: "ignore" });
|
|
64
|
+
assertionProcess = child;
|
|
65
|
+
child.once("error", () => {
|
|
66
|
+
if (assertionProcess === child) assertionProcess = null;
|
|
67
|
+
});
|
|
68
|
+
child.once("exit", () => {
|
|
69
|
+
if (assertionProcess === child) assertionProcess = null;
|
|
70
|
+
});
|
|
71
|
+
child.unref();
|
|
72
|
+
} catch {
|
|
73
|
+
if (assertionProcess === child) assertionProcess = null;
|
|
74
|
+
try {
|
|
75
|
+
child?.kill("SIGTERM");
|
|
76
|
+
} catch {
|
|
77
|
+
// Missing/unlaunchable caffeinate is a portable best-effort no-op.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
retain() {
|
|
84
|
+
if (disposed) return NOOP_RELEASE;
|
|
85
|
+
activeCount += 1;
|
|
86
|
+
if (activeCount === 1) startAssertion();
|
|
87
|
+
|
|
88
|
+
let released = false;
|
|
89
|
+
return () => {
|
|
90
|
+
if (released) return;
|
|
91
|
+
released = true;
|
|
92
|
+
if (disposed) return;
|
|
93
|
+
activeCount = Math.max(0, activeCount - 1);
|
|
94
|
+
if (activeCount === 0) stopAssertion();
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
dispose() {
|
|
98
|
+
if (disposed) return;
|
|
99
|
+
disposed = true;
|
|
100
|
+
activeCount = 0;
|
|
101
|
+
stopAssertion();
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function runWithActiveAgentSleepAssertion<T>(
|
|
107
|
+
controller: ActiveAgentSleepController,
|
|
108
|
+
launch: () => T | PromiseLike<T>,
|
|
109
|
+
): Promise<Awaited<T>> {
|
|
110
|
+
const release = controller.retain();
|
|
111
|
+
try {
|
|
112
|
+
return Promise.resolve(launch()).finally(release);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
release();
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Member } from "../../src/utils/models";
|
|
2
|
+
|
|
3
|
+
export const NESTED_READ_MODEL_SLOTS = [
|
|
4
|
+
"read-collect",
|
|
5
|
+
"read-review",
|
|
6
|
+
"read-analyze",
|
|
7
|
+
"read-critical",
|
|
8
|
+
] as const;
|
|
9
|
+
|
|
10
|
+
export type NestedReadModelSlot = (typeof NESTED_READ_MODEL_SLOTS)[number];
|
|
11
|
+
|
|
12
|
+
export const NESTED_DELEGATION_TOOL_NAMES = ["spawn_agent", "spawn_swarm_agents"] as const;
|
|
13
|
+
|
|
14
|
+
const ELIGIBLE_PARENT_MODEL_SLOTS = new Set(["write-feature", "write-critical"]);
|
|
15
|
+
|
|
16
|
+
export function isEligibleNestedReadParent(member: Member): boolean {
|
|
17
|
+
return member.role === "write"
|
|
18
|
+
&& member.delegationDepth === 0
|
|
19
|
+
&& member.allowNestedReadAgents === true
|
|
20
|
+
&& typeof member.modelSlot === "string"
|
|
21
|
+
&& ELIGIBLE_PARENT_MODEL_SLOTS.has(member.modelSlot);
|
|
22
|
+
}
|