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,52 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { installAgentNavigation, wrapEditorForAgentNavigation } from "./agent-navigation";
3
+
4
+ describe("agent navigation editor", () => {
5
+ it("opens the agent view on down only when the editor is empty", () => {
6
+ let text = "";
7
+ const originalHandleInput = vi.fn();
8
+ const editor = {
9
+ getText: () => text,
10
+ handleInput: originalHandleInput,
11
+ };
12
+ const openAgentView = vi.fn(() => true);
13
+ const wrapped = wrapEditorForAgentNavigation(editor, openAgentView);
14
+
15
+ wrapped.handleInput("\x1b[B");
16
+ expect(openAgentView).toHaveBeenCalledOnce();
17
+ expect(originalHandleInput).not.toHaveBeenCalled();
18
+
19
+ text = "draft";
20
+ wrapped.handleInput("\x1b[B");
21
+ expect(openAgentView).toHaveBeenCalledOnce();
22
+ expect(originalHandleInput).toHaveBeenCalledWith("\x1b[B");
23
+ });
24
+
25
+ it("replaces its own editor wrapper on reload instead of stacking wrappers", () => {
26
+ let currentFactory: any;
27
+ const ctx = {
28
+ mode: "tui",
29
+ ui: {
30
+ getEditorComponent: () => currentFactory,
31
+ setEditorComponent: vi.fn((factory: any) => { currentFactory = factory; }),
32
+ },
33
+ };
34
+
35
+ installAgentNavigation(ctx, { getAgents: () => [] });
36
+ const firstFactory = currentFactory;
37
+ installAgentNavigation(ctx, { getAgents: () => [] });
38
+
39
+ expect(currentFactory).not.toBe(firstFactory);
40
+ expect(currentFactory.piExtendedTeamsBaseFactory).toBeNull();
41
+ });
42
+
43
+ it("preserves normal down behavior when there is no active agent", () => {
44
+ const originalHandleInput = vi.fn();
45
+ const editor = { getText: () => "", handleInput: originalHandleInput };
46
+ const wrapped = wrapEditorForAgentNavigation(editor, () => false);
47
+
48
+ wrapped.handleInput("\x1b[B");
49
+
50
+ expect(originalHandleInput).toHaveBeenCalledWith("\x1b[B");
51
+ });
52
+ });
@@ -0,0 +1,55 @@
1
+ import { CustomEditor } from "@mariozechner/pi-coding-agent";
2
+ import { Key, matchesKey } from "@mariozechner/pi-tui";
3
+ import type { RunningReadAgent } from "../runtime/types";
4
+ import { openAgentFollowView } from "./agent-follow-view";
5
+
6
+ export interface AgentNavigationOptions {
7
+ getAgents(): RunningReadAgent[];
8
+ stopAgent?(name: string): void | Promise<void>;
9
+ sendMessage?(name: string, content: string): void | Promise<void>;
10
+ }
11
+
12
+ type NavigationEditorFactory = ((tui: any, theme: any, keybindings: any) => any) & {
13
+ piExtendedTeamsBaseFactory?: ((tui: any, theme: any, keybindings: any) => any) | null;
14
+ };
15
+
16
+ export function wrapEditorForAgentNavigation(editor: any, openAgentView: () => boolean): any {
17
+ const originalHandleInput = editor.handleInput.bind(editor);
18
+ editor.handleInput = (data: string) => {
19
+ if (matchesKey(data, Key.down) && editor.getText?.().length === 0 && openAgentView()) return;
20
+ originalHandleInput(data);
21
+ };
22
+ return editor;
23
+ }
24
+
25
+ export function installAgentNavigation(ctx: any, options: AgentNavigationOptions): void {
26
+ if (ctx.mode && ctx.mode !== "tui") return;
27
+ if (!ctx.ui?.setEditorComponent) return;
28
+
29
+ const currentFactory = ctx.ui.getEditorComponent?.() as NavigationEditorFactory | undefined;
30
+ const baseFactory = currentFactory && Object.hasOwn(currentFactory, "piExtendedTeamsBaseFactory")
31
+ ? currentFactory.piExtendedTeamsBaseFactory ?? undefined
32
+ : currentFactory;
33
+ let opening = false;
34
+
35
+ const navigationFactory: NavigationEditorFactory = (tui: any, theme: any, keybindings: any) => {
36
+ const editor = baseFactory
37
+ ? baseFactory(tui, theme, keybindings)
38
+ : new CustomEditor(tui, theme, keybindings);
39
+
40
+ return wrapEditorForAgentNavigation(editor, () => {
41
+ const agents = options.getAgents();
42
+ if (opening || agents.length === 0) return false;
43
+ opening = true;
44
+ void openAgentFollowView(ctx, {
45
+ getAgents: options.getAgents,
46
+ initialAgentName: agents.slice().sort((a, b) => a.name.localeCompare(b.name))[0]?.name,
47
+ stopAgent: options.stopAgent,
48
+ sendMessage: options.sendMessage,
49
+ }).catch(() => {}).finally(() => { opening = false; });
50
+ return true;
51
+ });
52
+ };
53
+ navigationFactory.piExtendedTeamsBaseFactory = baseFactory ?? null;
54
+ ctx.ui.setEditorComponent(navigationFactory);
55
+ }
@@ -0,0 +1,16 @@
1
+ export const ANSI_RESET = "\x1b[0m";
2
+ export const ANSI_PINK = "\x1b[38;5;213m";
3
+ export const ANSI_PURPLE = "\x1b[38;5;141m";
4
+ export const ANSI_DIM = "\x1b[2m";
5
+
6
+ export function pink(text: string): string {
7
+ return `${ANSI_PINK}${text}${ANSI_RESET}`;
8
+ }
9
+
10
+ export function purple(text: string): string {
11
+ return `${ANSI_PURPLE}${text}${ANSI_RESET}`;
12
+ }
13
+
14
+ export function dimAnsi(text: string): string {
15
+ return `${ANSI_DIM}${text}${ANSI_RESET}`;
16
+ }
@@ -0,0 +1,336 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import {
6
+ globalSettingsPath,
7
+ loadSettings,
8
+ projectSettingsPath,
9
+ replaceGlobalExtensionAllow,
10
+ } from "../../src/utils/settings";
11
+ import type { SpawnResourcePlan } from "../resources/spawn-resource-plan";
12
+
13
+ vi.mock("@mariozechner/pi-coding-agent", () => ({
14
+ getSettingsListTheme: () => ({
15
+ label: (text: string) => text,
16
+ value: (text: string) => text,
17
+ description: (text: string) => text,
18
+ cursor: ">",
19
+ hint: (text: string) => text,
20
+ }),
21
+ }));
22
+ import {
23
+ explicitAllowFromPicker,
24
+ formatExtensionsPlan,
25
+ registerExtensionsCommand,
26
+ } from "./extensions-command";
27
+
28
+ function makePlan(): SpawnResourcePlan {
29
+ return Object.freeze({
30
+ selectionMode: "explicit" as const,
31
+ extensionPaths: Object.freeze(["/extensions/selected/index.ts"]),
32
+ selfExtensionPath: "/pi-extended-teams/extensions/index.ts",
33
+ extensions: Object.freeze([
34
+ Object.freeze({
35
+ name: "selected",
36
+ selector: "selected",
37
+ identity: "/extensions/selected/index.ts",
38
+ path: "/extensions/selected/index.ts",
39
+ selected: true,
40
+ state: "selected" as const,
41
+ isSelf: false,
42
+ sourceInfo: Object.freeze({ path: "/extensions/selected/index.ts", source: "npm:selected-package", scope: "user" as const, origin: "package" as const }),
43
+ }),
44
+ Object.freeze({
45
+ name: "off",
46
+ selector: "off",
47
+ identity: "/project/.pi/extensions/off.ts",
48
+ path: "/project/.pi/extensions/off.ts",
49
+ selected: false,
50
+ state: "available" as const,
51
+ isSelf: false,
52
+ sourceInfo: Object.freeze({ path: "/project/.pi/extensions/off.ts", source: "local", scope: "project" as const, origin: "top-level" as const }),
53
+ }),
54
+ Object.freeze({
55
+ name: "pi-extended-teams",
56
+ selector: "pi-extended-teams",
57
+ identity: "/pi-extended-teams/extensions/index.ts",
58
+ path: "/pi-extended-teams/extensions/index.ts",
59
+ selected: false,
60
+ state: "self" as const,
61
+ isSelf: true,
62
+ sourceInfo: Object.freeze({ path: "/pi-extended-teams/extensions/index.ts", source: "local", scope: "user" as const, origin: "top-level" as const }),
63
+ }),
64
+ ]),
65
+ diagnostics: Object.freeze([
66
+ Object.freeze({
67
+ code: "stale-selection" as const,
68
+ configuredEntry: "missing-extension",
69
+ message: "Configured extension is not observable in the lead Pi session: missing-extension",
70
+ }),
71
+ ]),
72
+ skills: "all" as const,
73
+ trust: Object.freeze({ cwd: "/project", projectTrusted: true }),
74
+ });
75
+ }
76
+
77
+ function theme() {
78
+ return {
79
+ fg: (_color: string, value: string) => value,
80
+ bold: (value: string) => value,
81
+ };
82
+ }
83
+
84
+ function setupFileBackedCommand(options: {
85
+ homeDir: string;
86
+ projectDir: string;
87
+ trusted: boolean;
88
+ custom?: (factory: any) => Promise<any>;
89
+ }) {
90
+ const commands = new Map<string, any>();
91
+ const pi = { registerCommand: vi.fn((name: string, command: any) => commands.set(name, command)) };
92
+ const ctx = {
93
+ cwd: options.projectDir,
94
+ mode: "tui",
95
+ isProjectTrusted: () => options.trusted,
96
+ ui: {
97
+ notify: vi.fn(),
98
+ custom: options.custom ? vi.fn(options.custom) : vi.fn(),
99
+ },
100
+ };
101
+ registerExtensionsCommand(pi, {
102
+ createResourcePlan: vi.fn(async () => makePlan()),
103
+ loadTeamsSettings: (loadOptions) => loadSettings({ homeDir: options.homeDir, projectDir: loadOptions?.projectDir }),
104
+ saveAllow: (allow) => replaceGlobalExtensionAllow(allow, { homeDir: options.homeDir }),
105
+ getGlobalSettingsPath: () => globalSettingsPath(options.homeDir),
106
+ });
107
+ return { command: commands.get("agents-extensions"), ctx };
108
+ }
109
+
110
+ function setupCommand(custom?: (factory: any) => Promise<any>) {
111
+ const commands = new Map<string, any>();
112
+ const pi = { registerCommand: vi.fn((name: string, command: any) => commands.set(name, command)) };
113
+ const saveAllow = vi.fn();
114
+ const createResourcePlan = vi.fn(async () => makePlan());
115
+ const loadTeamsSettings = vi.fn(() => ({ extensions: { allow: ["selected", "missing-extension"], block: [] } } as any));
116
+ registerExtensionsCommand(pi, {
117
+ createResourcePlan,
118
+ loadTeamsSettings,
119
+ saveAllow,
120
+ });
121
+ const ctx = {
122
+ cwd: "/project",
123
+ mode: "tui",
124
+ isProjectTrusted: () => true,
125
+ ui: {
126
+ notify: vi.fn(),
127
+ custom: custom ? vi.fn(custom) : vi.fn(),
128
+ },
129
+ };
130
+ return { command: commands.get("agents-extensions"), ctx, saveAllow, createResourcePlan, loadTeamsSettings, pi };
131
+ }
132
+
133
+ describe("/agents-extensions", () => {
134
+ it("formats textual enabled, disabled, self, missing, and provenance states", async () => {
135
+ const plan = makePlan();
136
+ const text = formatExtensionsPlan(plan);
137
+ expect(text).toContain("Skills use normal Pi discovery and trust. pi-extended-teams is handled internally.");
138
+ expect(text).toContain("event-only extensions cannot be propagated");
139
+ expect(text).toContain("enabled · selected · user · package · npm:selected-package");
140
+ expect(text).toContain("disabled · off.ts · project · top-level · local");
141
+ expect(text).toContain("internal · extensions · user · top-level · local");
142
+ expect(text).toContain("Configured extension is not observable in the lead Pi session: missing-extension");
143
+
144
+ const setup = setupCommand();
145
+ await setup.command.handler("list", setup.ctx);
146
+ expect(setup.loadTeamsSettings).toHaveBeenCalledWith({ projectDir: "/project" });
147
+ expect(setup.createResourcePlan).toHaveBeenCalledWith(expect.objectContaining({
148
+ cwd: "/project",
149
+ projectTrusted: true,
150
+ }));
151
+ expect(setup.ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("missing-extension"), "info");
152
+ expect(setup.saveAllow).not.toHaveBeenCalled();
153
+ });
154
+
155
+ it("does not read project-local policy when the parent project is untrusted", async () => {
156
+ const setup = setupCommand();
157
+ setup.ctx.mode = "print";
158
+ setup.ctx.isProjectTrusted = () => false;
159
+
160
+ await setup.command.handler("list", setup.ctx);
161
+
162
+ expect(setup.loadTeamsSettings).toHaveBeenCalledWith({ projectDir: undefined });
163
+ expect(setup.createResourcePlan).toHaveBeenCalledWith(expect.objectContaining({ projectTrusted: false }));
164
+ });
165
+
166
+ it("writes nothing when the native SettingsList is cancelled", async () => {
167
+ const setup = setupCommand(async (factory: any) => {
168
+ return new Promise((resolve) => {
169
+ const component = factory({ requestRender: vi.fn() }, theme(), {}, resolve);
170
+ component.handleInput("\u001b");
171
+ });
172
+ });
173
+
174
+ await setup.command.handler("", setup.ctx);
175
+ expect(setup.saveAllow).not.toHaveBeenCalled();
176
+ });
177
+
178
+ it("saves explicit resolver-ordered paths while retaining stale configured entries", async () => {
179
+ const setup = setupCommand(async (factory: any) => {
180
+ return new Promise((resolve) => {
181
+ const component = factory({ requestRender: vi.fn() }, theme(), {}, resolve);
182
+ component.handleInput("\u001b[B");
183
+ component.handleInput("\r");
184
+ component.handleInput("\u0013");
185
+ });
186
+ });
187
+
188
+ await setup.command.handler("", setup.ctx);
189
+ expect(setup.saveAllow).toHaveBeenCalledWith(["missing-extension"]);
190
+ expect(setup.ctx.ui.notify).toHaveBeenCalledWith("Spawned-agent extension choices saved.", "info");
191
+ });
192
+
193
+ it("supports durable default and none policies without opening the selector", async () => {
194
+ const setup = setupCommand();
195
+ await setup.command.handler("default", setup.ctx);
196
+ await setup.command.handler("none", setup.ctx);
197
+
198
+ expect(setup.saveAllow.mock.calls).toEqual([[null], [[]]]);
199
+ expect(setup.createResourcePlan).not.toHaveBeenCalled();
200
+ expect(setup.ctx.ui.custom).not.toHaveBeenCalled();
201
+ });
202
+
203
+ it("saves default and none globally while a trusted project policy remains effective", async () => {
204
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "pet-command-home-"));
205
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "pet-command-project-"));
206
+ try {
207
+ const globalPath = globalSettingsPath(homeDir);
208
+ const projectPath = projectSettingsPath(projectDir);
209
+ fs.mkdirSync(path.dirname(globalPath), { recursive: true });
210
+ fs.mkdirSync(path.dirname(projectPath), { recursive: true });
211
+ fs.writeFileSync(globalPath, JSON.stringify({ unknownGlobal: true, extensions: { allow: ["global"], block: ["blocked"] } }));
212
+ fs.writeFileSync(projectPath, JSON.stringify({ unknownProject: true, extensions: { allow: ["project"], block: ["project-block"] } }));
213
+ const projectBefore = fs.readFileSync(projectPath, "utf-8");
214
+ const setup = setupFileBackedCommand({ homeDir, projectDir, trusted: true });
215
+
216
+ await setup.command.handler("default", setup.ctx);
217
+ expect(JSON.parse(fs.readFileSync(globalPath, "utf-8"))).toMatchObject({
218
+ unknownGlobal: true,
219
+ extensions: { allow: null, block: ["blocked"] },
220
+ });
221
+ expect(loadSettings({ homeDir, projectDir }).extensions.allow).toEqual(["project"]);
222
+ expect(fs.readFileSync(projectPath, "utf-8")).toBe(projectBefore);
223
+ expect(setup.ctx.ui.notify).toHaveBeenLastCalledWith(
224
+ expect.stringContaining(`Global spawned-agent extension policy was saved to ${globalPath}`),
225
+ "warning",
226
+ );
227
+ expect(setup.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining(projectPath), "warning");
228
+
229
+ await setup.command.handler("none", setup.ctx);
230
+ expect(JSON.parse(fs.readFileSync(globalPath, "utf-8")).extensions.allow).toEqual([]);
231
+ expect(loadSettings({ homeDir, projectDir }).extensions.allow).toEqual(["project"]);
232
+ } finally {
233
+ fs.rmSync(homeDir, { recursive: true, force: true });
234
+ fs.rmSync(projectDir, { recursive: true, force: true });
235
+ }
236
+ });
237
+
238
+ it("saves picker choices globally but leaves a trusted project override authoritative", async () => {
239
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "pet-command-home-"));
240
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "pet-command-project-"));
241
+ try {
242
+ const globalPath = globalSettingsPath(homeDir);
243
+ const projectPath = projectSettingsPath(projectDir);
244
+ fs.mkdirSync(path.dirname(globalPath), { recursive: true });
245
+ fs.mkdirSync(path.dirname(projectPath), { recursive: true });
246
+ fs.writeFileSync(globalPath, JSON.stringify({ extensions: { allow: ["global"], block: ["blocked"], unknownNested: true } }));
247
+ fs.writeFileSync(projectPath, JSON.stringify({ extensions: { allow: [], block: ["project-block"] }, projectOnly: true }));
248
+ const projectBefore = fs.readFileSync(projectPath, "utf-8");
249
+ const setup = setupFileBackedCommand({
250
+ homeDir,
251
+ projectDir,
252
+ trusted: true,
253
+ custom: async () => ({ mode: "explicit", selectedIdentities: ["/extensions/selected/index.ts"] }),
254
+ });
255
+
256
+ await setup.command.handler("", setup.ctx);
257
+
258
+ expect(JSON.parse(fs.readFileSync(globalPath, "utf-8"))).toMatchObject({
259
+ extensions: { allow: ["/extensions/selected/index.ts", "missing-extension"], block: ["blocked"], unknownNested: true },
260
+ });
261
+ expect(fs.readFileSync(projectPath, "utf-8")).toBe(projectBefore);
262
+ expect(loadSettings({ homeDir, projectDir }).extensions.allow).toEqual([]);
263
+ expect(setup.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining(projectPath), "warning");
264
+ } finally {
265
+ fs.rmSync(homeDir, { recursive: true, force: true });
266
+ fs.rmSync(projectDir, { recursive: true, force: true });
267
+ }
268
+ });
269
+
270
+ it("ignores an untrusted project override and leaves files unchanged when picker is cancelled", async () => {
271
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "pet-command-home-"));
272
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "pet-command-project-"));
273
+ try {
274
+ const globalPath = globalSettingsPath(homeDir);
275
+ const projectPath = projectSettingsPath(projectDir);
276
+ fs.mkdirSync(path.dirname(globalPath), { recursive: true });
277
+ fs.mkdirSync(path.dirname(projectPath), { recursive: true });
278
+ fs.writeFileSync(globalPath, JSON.stringify({ extensions: { allow: ["global"], block: ["blocked"] } }));
279
+ fs.writeFileSync(projectPath, JSON.stringify({ extensions: { allow: ["project"] } }));
280
+ const projectBefore = fs.readFileSync(projectPath, "utf-8");
281
+ const setup = setupFileBackedCommand({ homeDir, projectDir, trusted: false });
282
+
283
+ await setup.command.handler("none", setup.ctx);
284
+ expect(JSON.parse(fs.readFileSync(globalPath, "utf-8")).extensions.allow).toEqual([]);
285
+ expect(loadSettings({ homeDir }).extensions.allow).toEqual([]);
286
+ expect(fs.readFileSync(projectPath, "utf-8")).toBe(projectBefore);
287
+ expect(setup.ctx.ui.notify).toHaveBeenLastCalledWith(
288
+ "Spawned agents will load no external extensions. Skills remain enabled.",
289
+ "info",
290
+ );
291
+
292
+ const globalBeforeCancel = fs.readFileSync(globalPath, "utf-8");
293
+ const cancelled = setupFileBackedCommand({
294
+ homeDir,
295
+ projectDir,
296
+ trusted: true,
297
+ custom: async () => null,
298
+ });
299
+ await cancelled.command.handler("", cancelled.ctx);
300
+ expect(fs.readFileSync(globalPath, "utf-8")).toBe(globalBeforeCancel);
301
+ expect(fs.readFileSync(projectPath, "utf-8")).toBe(projectBefore);
302
+ expect(loadSettings({ homeDir, projectDir }).extensions.allow).toEqual(["project"]);
303
+ } finally {
304
+ fs.rmSync(homeDir, { recursive: true, force: true });
305
+ fs.rmSync(projectDir, { recursive: true, force: true });
306
+ }
307
+ });
308
+
309
+ it("persists canonical identities, excludes self, and preserves stale or blocked entries", () => {
310
+ const plan = makePlan();
311
+ const withBlockedDiagnostic: SpawnResourcePlan = Object.freeze({
312
+ ...plan,
313
+ diagnostics: Object.freeze([
314
+ ...plan.diagnostics,
315
+ Object.freeze({
316
+ code: "blocked-selection" as const,
317
+ configuredEntry: "/extensions/blocked/index.ts",
318
+ message: "Configured extension is blocked for spawned agents: /extensions/blocked/index.ts",
319
+ }),
320
+ ]),
321
+ });
322
+
323
+ expect(explicitAllowFromPicker(withBlockedDiagnostic, {
324
+ mode: "explicit",
325
+ selectedIdentities: [
326
+ "/extensions/selected/index.ts",
327
+ "/pi-extended-teams/extensions/index.ts",
328
+ ],
329
+ })).toEqual([
330
+ "/extensions/selected/index.ts",
331
+ "missing-extension",
332
+ "/extensions/blocked/index.ts",
333
+ ]);
334
+ expect(explicitAllowFromPicker(plan, { mode: "default", selectedIdentities: [] })).toBeNull();
335
+ });
336
+ });