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,191 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { buildExtensionArgs, buildPiCommand, checkChildPiModelAvailability, getPiExtendedTeamsExtensionSource, getPiLaunchCommand, isModelListed, resolvePiExtendedTeamsExtensionSource } from "./pi-command";
|
|
7
|
+
|
|
8
|
+
const originalExtendedSource = process.env.PI_EXTENDED_TEAMS_EXTENSION_SOURCE;
|
|
9
|
+
const originalLegacySource = process.env.PI_TEAMS_EXTENSION_SOURCE;
|
|
10
|
+
const originalPreflight = process.env.PI_EXTENDED_TEAMS_MODEL_PREFLIGHT;
|
|
11
|
+
const originalLegacyPreflight = process.env.PI_TEAMS_MODEL_PREFLIGHT;
|
|
12
|
+
|
|
13
|
+
function restoreEnv() {
|
|
14
|
+
if (originalExtendedSource === undefined) delete process.env.PI_EXTENDED_TEAMS_EXTENSION_SOURCE;
|
|
15
|
+
else process.env.PI_EXTENDED_TEAMS_EXTENSION_SOURCE = originalExtendedSource;
|
|
16
|
+
|
|
17
|
+
if (originalLegacySource === undefined) delete process.env.PI_TEAMS_EXTENSION_SOURCE;
|
|
18
|
+
else process.env.PI_TEAMS_EXTENSION_SOURCE = originalLegacySource;
|
|
19
|
+
|
|
20
|
+
if (originalPreflight === undefined) delete process.env.PI_EXTENDED_TEAMS_MODEL_PREFLIGHT;
|
|
21
|
+
else process.env.PI_EXTENDED_TEAMS_MODEL_PREFLIGHT = originalPreflight;
|
|
22
|
+
|
|
23
|
+
if (originalLegacyPreflight === undefined) delete process.env.PI_TEAMS_MODEL_PREFLIGHT;
|
|
24
|
+
else process.env.PI_TEAMS_MODEL_PREFLIGHT = originalLegacyPreflight;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("pi command helpers", () => {
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
restoreEnv();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("quotes the pi launch command so the shell cannot expand the install path", () => {
|
|
33
|
+
const originalArgv1 = process.argv[1];
|
|
34
|
+
try {
|
|
35
|
+
process.argv[1] = "/tmp/pi $(id -u)/dist/cli.js";
|
|
36
|
+
const launch = getPiLaunchCommand();
|
|
37
|
+
|
|
38
|
+
// Match the tmux adapter's `sh -c` boundary.
|
|
39
|
+
const echoed = spawnSync("sh", ["-c", launch.replace(/^node /, "echo ")], { encoding: "utf-8" });
|
|
40
|
+
expect(echoed.stdout.trim()).toBe("/tmp/pi $(id -u)/dist/cli.js");
|
|
41
|
+
} finally {
|
|
42
|
+
if (originalArgv1 === undefined) delete (process.argv as unknown as Record<number, string>)[1];
|
|
43
|
+
else process.argv[1] = originalArgv1;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("falls back to a nearby checkout from cwd when the installed module path cannot find the extension", () => {
|
|
48
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pet-pi-cwd-"));
|
|
49
|
+
try {
|
|
50
|
+
const cwd = path.join(root, "pi-extended-teams");
|
|
51
|
+
const extensionPath = path.join(cwd, "extensions", "index.ts");
|
|
52
|
+
fs.mkdirSync(path.dirname(extensionPath), { recursive: true });
|
|
53
|
+
fs.writeFileSync(extensionPath, "export default function () {}\n");
|
|
54
|
+
|
|
55
|
+
expect(resolvePiExtendedTeamsExtensionSource({
|
|
56
|
+
env: {},
|
|
57
|
+
filename: path.join(root, "missing-install", "extensions", "internal", "pi-command.js"),
|
|
58
|
+
cwd,
|
|
59
|
+
})).toBe(extensionPath);
|
|
60
|
+
} finally {
|
|
61
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("falls back to the extension entrypoint instead of the internal command helper", () => {
|
|
66
|
+
delete process.env.PI_EXTENDED_TEAMS_EXTENSION_SOURCE;
|
|
67
|
+
delete process.env.PI_TEAMS_EXTENSION_SOURCE;
|
|
68
|
+
|
|
69
|
+
const source = getPiExtendedTeamsExtensionSource().replace(/\\/g, "/");
|
|
70
|
+
|
|
71
|
+
expect(source).toMatch(/\/extensions\/index\.(ts|js|mjs|cjs)$/);
|
|
72
|
+
expect(source).not.toContain("/extensions/internal/");
|
|
73
|
+
expect(buildExtensionArgs()).toContain("--extension");
|
|
74
|
+
expect(buildPiCommand("pi", "provider/model")).toContain("/extensions/index.");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("keeps normal Pi skill discovery enabled and propagates an untrusted parent", () => {
|
|
78
|
+
const command = buildPiCommand("pi", "provider/model", "high", [], false);
|
|
79
|
+
expect(command).toContain("--no-approve --no-extensions");
|
|
80
|
+
expect(command).toContain("--model 'provider/model:high'");
|
|
81
|
+
expect(command).not.toContain("--no-skills");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("routes spawned Pi sessions to an explicitly quoted private directory", () => {
|
|
85
|
+
const command = buildPiCommand(
|
|
86
|
+
"pi",
|
|
87
|
+
"provider/model",
|
|
88
|
+
"high",
|
|
89
|
+
[],
|
|
90
|
+
false,
|
|
91
|
+
undefined,
|
|
92
|
+
"/tmp/$HOME/private agent sessions",
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(command).toContain("--session-dir '/tmp/$HOME/private agent sessions'");
|
|
96
|
+
expect(command).toContain("--model 'provider/model:high'");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("still honors explicit extension source environment overrides and loads self exactly once", () => {
|
|
100
|
+
process.env.PI_EXTENDED_TEAMS_EXTENSION_SOURCE = "/tmp/custom-extension.ts";
|
|
101
|
+
delete process.env.PI_TEAMS_EXTENSION_SOURCE;
|
|
102
|
+
|
|
103
|
+
expect(getPiExtendedTeamsExtensionSource()).toBe("/tmp/custom-extension.ts");
|
|
104
|
+
const args = buildExtensionArgs(["/tmp/custom-extension.ts", "/tmp/external.ts", "/tmp/external.ts"]);
|
|
105
|
+
expect(args.match(/'\/tmp\/custom-extension\.ts'/g)).toHaveLength(1);
|
|
106
|
+
expect(args.match(/'\/tmp\/external\.ts'/g)).toHaveLength(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("canonical-dedupes self and selected extension symlinks", () => {
|
|
110
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pet-pi-extension-args-"));
|
|
111
|
+
try {
|
|
112
|
+
const self = path.join(root, "self.ts");
|
|
113
|
+
const selfLink = path.join(root, "self-link.ts");
|
|
114
|
+
const selected = path.join(root, "selected.ts");
|
|
115
|
+
const selectedLink = path.join(root, "selected-link.ts");
|
|
116
|
+
fs.writeFileSync(self, "export default function () {}\n");
|
|
117
|
+
fs.writeFileSync(selected, "export default function () {}\n");
|
|
118
|
+
fs.symlinkSync(self, selfLink);
|
|
119
|
+
fs.symlinkSync(selected, selectedLink);
|
|
120
|
+
|
|
121
|
+
const args = buildExtensionArgs([selfLink, selected, selectedLink], true, self);
|
|
122
|
+
expect(args).toContain(`--approve --no-extensions --extension '${self}'`);
|
|
123
|
+
expect(args.match(/--extension/g)).toHaveLength(2);
|
|
124
|
+
expect(args).toContain(`--extension '${selected}'`);
|
|
125
|
+
expect(args).not.toContain(selfLink);
|
|
126
|
+
expect(args).not.toContain(selectedLink);
|
|
127
|
+
} finally {
|
|
128
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("single-quotes arbitrary selected paths so sh -c cannot expand dollar signs", () => {
|
|
133
|
+
const args = buildExtensionArgs(["/tmp/$HOME/selected extension.ts"]);
|
|
134
|
+
expect(args).toContain("--extension '/tmp/$HOME/selected extension.ts'");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("matches fully qualified models in pi list output", () => {
|
|
138
|
+
const output = [
|
|
139
|
+
"provider model context max-out thinking images",
|
|
140
|
+
"anthropic-250k-prefer-using-this-one claude-opus-4-8 250K 128K yes yes",
|
|
141
|
+
"anthropic claude-opus-4-8 1M 128K yes yes",
|
|
142
|
+
].join("\n");
|
|
143
|
+
|
|
144
|
+
expect(isModelListed(output, "anthropic-250k-prefer-using-this-one/claude-opus-4-8")).toBe(true);
|
|
145
|
+
expect(isModelListed(output, "anthropic-250k-prefer-using-this-one/claude-opus-4-7")).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("checks the child Pi model registry with the spawn extension set", () => {
|
|
149
|
+
delete process.env.PI_EXTENDED_TEAMS_MODEL_PREFLIGHT;
|
|
150
|
+
delete process.env.PI_TEAMS_MODEL_PREFLIGHT;
|
|
151
|
+
|
|
152
|
+
const result = checkChildPiModelAvailability("pi", "provider/model", ["/tmp/bootstrap"], {
|
|
153
|
+
projectTrusted: true,
|
|
154
|
+
run: (command) => ({
|
|
155
|
+
status: 0,
|
|
156
|
+
stdout: command.includes("--extension '/tmp/bootstrap'") ? "provider model context" : "",
|
|
157
|
+
stderr: "",
|
|
158
|
+
}),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(result.status).toBe("available");
|
|
162
|
+
expect(result.command).toContain("--approve --no-extensions");
|
|
163
|
+
expect(result.command).toContain("--extension '/tmp/bootstrap'");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("checks stderr when Pi writes the model table there", () => {
|
|
167
|
+
delete process.env.PI_EXTENDED_TEAMS_MODEL_PREFLIGHT;
|
|
168
|
+
delete process.env.PI_TEAMS_MODEL_PREFLIGHT;
|
|
169
|
+
|
|
170
|
+
const result = checkChildPiModelAvailability("pi", "provider/model", ["/tmp/bootstrap"], {
|
|
171
|
+
run: () => ({
|
|
172
|
+
status: 0,
|
|
173
|
+
stdout: "",
|
|
174
|
+
stderr: "provider model context",
|
|
175
|
+
}),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(result.status).toBe("available");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("skips child Pi model checks when preflight is disabled", () => {
|
|
182
|
+
process.env.PI_EXTENDED_TEAMS_MODEL_PREFLIGHT = "0";
|
|
183
|
+
|
|
184
|
+
const result = checkChildPiModelAvailability("pi", "provider/model", ["/tmp/bootstrap"], {
|
|
185
|
+
run: () => { throw new Error("preflight should not run"); },
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
expect(result.status).toBe("skipped");
|
|
189
|
+
expect(result.command).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import * as nodeFs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build the command used to relaunch pi for teammate processes.
|
|
7
|
+
*
|
|
8
|
+
* There are three common cases:
|
|
9
|
+
* - npm/node install: pi runs as `node .../dist/cli.js`
|
|
10
|
+
* - standalone compiled binary: process.execPath is the actual `pi` executable
|
|
11
|
+
* - shim-based installs (e.g. Volta): process.execPath is `node` and argv[1]
|
|
12
|
+
* may be a shim path, so the safest relaunch command is plain `pi`
|
|
13
|
+
*/
|
|
14
|
+
export function getPiLaunchCommand(): string {
|
|
15
|
+
const argv1 = process.argv[1];
|
|
16
|
+
const execPath = process.execPath;
|
|
17
|
+
|
|
18
|
+
if (argv1) {
|
|
19
|
+
const ext = path.extname(argv1).toLowerCase();
|
|
20
|
+
const looksLikeScript = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"].includes(ext)
|
|
21
|
+
|| /(?:^|[/\\])dist[/\\]cli\.js$/i.test(argv1);
|
|
22
|
+
|
|
23
|
+
if (looksLikeScript) {
|
|
24
|
+
return `node ${shellQuote(argv1)}`;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (execPath) {
|
|
29
|
+
const base = path.basename(execPath).toLowerCase();
|
|
30
|
+
if (base !== "node" && base !== "node.exe" && base !== "bun" && base !== "bun.exe") {
|
|
31
|
+
return shellQuote(execPath);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return "pi";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function shellQuote(value: string): string {
|
|
39
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function extensionPathIdentity(source: string): string {
|
|
43
|
+
const absolute = path.resolve(source);
|
|
44
|
+
try {
|
|
45
|
+
return nodeFs.realpathSync(absolute);
|
|
46
|
+
} catch {
|
|
47
|
+
return absolute;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const EXTENSION_ENTRYPOINTS = ["index.ts", "index.js", "index.mjs", "index.cjs"];
|
|
52
|
+
const DISABLED_PREFLIGHT_VALUES = new Set(["", "0", "false", "no", "off"]);
|
|
53
|
+
|
|
54
|
+
interface ExtensionSourceFileSystem {
|
|
55
|
+
existsSync(filePath: string): boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ExtensionSourceResolutionOptions {
|
|
59
|
+
env?: Record<string, string | undefined>;
|
|
60
|
+
filename?: string;
|
|
61
|
+
cwd?: string;
|
|
62
|
+
fileSystem?: ExtensionSourceFileSystem;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const defaultFileSystem: ExtensionSourceFileSystem = {
|
|
66
|
+
existsSync: nodeFs.existsSync,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function firstExistingPath(candidates: string[], fileSystem: ExtensionSourceFileSystem): string | null {
|
|
70
|
+
return candidates.find(candidate => fileSystem.existsSync(candidate)) ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function findExtensionSourceFromCwd(cwd: string, fileSystem: ExtensionSourceFileSystem): string | null {
|
|
74
|
+
return firstExistingPath([
|
|
75
|
+
...EXTENSION_ENTRYPOINTS.map(entrypoint => path.join(cwd, "extensions", entrypoint)),
|
|
76
|
+
...EXTENSION_ENTRYPOINTS.map(entrypoint => path.join(cwd, "pi-extended-teams", "extensions", entrypoint)),
|
|
77
|
+
...EXTENSION_ENTRYPOINTS.map(entrypoint => path.join(path.dirname(cwd), "pi-extended-teams", "extensions", entrypoint)),
|
|
78
|
+
], fileSystem);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getExtensionEntrypointFallback(options: Required<Omit<ExtensionSourceResolutionOptions, "env">>): string {
|
|
82
|
+
const ext = path.extname(options.filename) || ".js";
|
|
83
|
+
const moduleRelativeCandidate = path.join(path.dirname(path.dirname(options.filename)), `index${ext}`);
|
|
84
|
+
const moduleRelativeCandidates = [
|
|
85
|
+
moduleRelativeCandidate,
|
|
86
|
+
...EXTENSION_ENTRYPOINTS.map(entrypoint => path.join(path.dirname(path.dirname(options.filename)), entrypoint)),
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
return firstExistingPath(moduleRelativeCandidates, options.fileSystem)
|
|
90
|
+
?? findExtensionSourceFromCwd(options.cwd, options.fileSystem)
|
|
91
|
+
?? moduleRelativeCandidate;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function resolvePiExtendedTeamsExtensionSource(options: ExtensionSourceResolutionOptions = {}): string {
|
|
95
|
+
const env = options.env ?? process.env;
|
|
96
|
+
const explicitSource = env.PI_EXTENDED_TEAMS_EXTENSION_SOURCE || env.PI_TEAMS_EXTENSION_SOURCE;
|
|
97
|
+
if (explicitSource) return explicitSource;
|
|
98
|
+
|
|
99
|
+
return getExtensionEntrypointFallback({
|
|
100
|
+
filename: options.filename ?? __filename,
|
|
101
|
+
cwd: options.cwd ?? process.cwd(),
|
|
102
|
+
fileSystem: options.fileSystem ?? defaultFileSystem,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function getPiExtendedTeamsExtensionSource(): string {
|
|
107
|
+
return resolvePiExtendedTeamsExtensionSource();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function buildExtensionArgs(
|
|
111
|
+
allowedExtensions: readonly string[] = [],
|
|
112
|
+
projectTrusted?: boolean,
|
|
113
|
+
selfExtensionSource = getPiExtendedTeamsExtensionSource(),
|
|
114
|
+
): string {
|
|
115
|
+
const trustArgs = projectTrusted === undefined ? [] : [projectTrusted ? "--approve" : "--no-approve"];
|
|
116
|
+
const parts = [...trustArgs, "--no-extensions", "--extension", shellQuote(selfExtensionSource)];
|
|
117
|
+
const seen = new Set([extensionPathIdentity(selfExtensionSource)]);
|
|
118
|
+
for (const source of allowedExtensions) {
|
|
119
|
+
const identity = extensionPathIdentity(source);
|
|
120
|
+
if (seen.has(identity)) continue;
|
|
121
|
+
seen.add(identity);
|
|
122
|
+
parts.push("--extension", shellQuote(source));
|
|
123
|
+
}
|
|
124
|
+
return parts.join(" ");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function buildPiCommand(
|
|
128
|
+
piBinary: string,
|
|
129
|
+
chosenModel?: string,
|
|
130
|
+
thinking?: string,
|
|
131
|
+
allowedExtensions: readonly string[] = [],
|
|
132
|
+
projectTrusted?: boolean,
|
|
133
|
+
selfExtensionSource?: string,
|
|
134
|
+
sessionDir?: string,
|
|
135
|
+
): string {
|
|
136
|
+
const extensionArgs = buildExtensionArgs(allowedExtensions, projectTrusted, selfExtensionSource);
|
|
137
|
+
const sessionArgs = sessionDir ? ` --session-dir ${shellQuote(sessionDir)}` : "";
|
|
138
|
+
|
|
139
|
+
if (chosenModel) {
|
|
140
|
+
const modelArg = thinking ? `${chosenModel}:${thinking}` : chosenModel;
|
|
141
|
+
return `${piBinary} ${extensionArgs}${sessionArgs} --model ${shellQuote(modelArg)}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (thinking) {
|
|
145
|
+
return `${piBinary} ${extensionArgs}${sessionArgs} --thinking ${shellQuote(thinking)}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return `${piBinary} ${extensionArgs}${sessionArgs}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export type ChildPiModelAvailabilityStatus = "available" | "missing" | "unknown" | "skipped";
|
|
152
|
+
|
|
153
|
+
export interface ChildPiModelAvailability {
|
|
154
|
+
status: ChildPiModelAvailabilityStatus;
|
|
155
|
+
command: string | null;
|
|
156
|
+
stdout: string;
|
|
157
|
+
stderr: string;
|
|
158
|
+
exitStatus: number | null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface ChildPiModelAvailabilityOptions {
|
|
162
|
+
run?: (command: string) => { status: number | null; stdout: string; stderr: string };
|
|
163
|
+
timeoutMs?: number;
|
|
164
|
+
projectTrusted?: boolean;
|
|
165
|
+
selfExtensionSource?: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function defaultRun(command: string, timeoutMs: number): { status: number | null; stdout: string; stderr: string } {
|
|
169
|
+
const result = spawnSync("sh", ["-c", command], { encoding: "utf-8", timeout: timeoutMs });
|
|
170
|
+
return {
|
|
171
|
+
status: result.status,
|
|
172
|
+
stdout: result.stdout?.toString() ?? "",
|
|
173
|
+
stderr: result.stderr?.toString() ?? result.error?.message ?? "",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseModelSpecifier(model: string): { provider: string; model: string } | null {
|
|
178
|
+
const slashIndex = model.indexOf("/");
|
|
179
|
+
if (slashIndex === -1) return null;
|
|
180
|
+
|
|
181
|
+
const provider = model.slice(0, slashIndex).trim();
|
|
182
|
+
const modelId = model.slice(slashIndex + 1).trim();
|
|
183
|
+
if (!provider || !modelId) return null;
|
|
184
|
+
|
|
185
|
+
return { provider, model: modelId };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function isModelListed(output: string, chosenModel: string): boolean {
|
|
189
|
+
const parsed = parseModelSpecifier(chosenModel);
|
|
190
|
+
if (!parsed) return false;
|
|
191
|
+
|
|
192
|
+
return output.split("\n").some((line) => {
|
|
193
|
+
const [provider, model] = line.trim().split(/\s+/);
|
|
194
|
+
return provider === parsed.provider && model === parsed.model;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function checkChildPiModelAvailability(
|
|
199
|
+
piBinary: string,
|
|
200
|
+
chosenModel: string | undefined,
|
|
201
|
+
allowedExtensions: readonly string[] = [],
|
|
202
|
+
options: ChildPiModelAvailabilityOptions = {}
|
|
203
|
+
): ChildPiModelAvailability {
|
|
204
|
+
const preflightSetting = process.env.PI_EXTENDED_TEAMS_MODEL_PREFLIGHT ?? process.env.PI_TEAMS_MODEL_PREFLIGHT;
|
|
205
|
+
if (!chosenModel || DISABLED_PREFLIGHT_VALUES.has(preflightSetting?.trim().toLowerCase() ?? "enabled")) {
|
|
206
|
+
return { status: "skipped", command: null, stdout: "", stderr: "", exitStatus: null };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const command = `${piBinary} ${buildExtensionArgs(allowedExtensions, options.projectTrusted, options.selfExtensionSource)} --list-models`;
|
|
210
|
+
const run = options.run ?? ((cmd: string) => defaultRun(cmd, options.timeoutMs ?? 10000));
|
|
211
|
+
const result = run(command);
|
|
212
|
+
const modelListOutput = `${result.stdout}\n${result.stderr}`;
|
|
213
|
+
const status = result.status === 0
|
|
214
|
+
? isModelListed(modelListOutput, chosenModel) ? "available" : "missing"
|
|
215
|
+
: "unknown";
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
status,
|
|
219
|
+
command,
|
|
220
|
+
stdout: result.stdout,
|
|
221
|
+
stderr: result.stderr,
|
|
222
|
+
exitStatus: result.status,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { resolvePiRuntimeModulePath } from "./pi-runtime-api";
|
|
6
|
+
|
|
7
|
+
const roots: string[] = [];
|
|
8
|
+
|
|
9
|
+
function packageEntrypoint(root: string, scope: string, name: string): string {
|
|
10
|
+
const packageDir = path.join(root, "node_modules", scope, name);
|
|
11
|
+
const entrypoint = path.join(packageDir, "index.js");
|
|
12
|
+
fs.mkdirSync(packageDir, { recursive: true });
|
|
13
|
+
fs.writeFileSync(path.join(packageDir, "package.json"), JSON.stringify({
|
|
14
|
+
name: `${scope}/${name}`,
|
|
15
|
+
type: "module",
|
|
16
|
+
exports: "./index.js",
|
|
17
|
+
}));
|
|
18
|
+
fs.writeFileSync(entrypoint, "export const marker = true;\n");
|
|
19
|
+
return entrypoint;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("active Pi runtime API resolution", () => {
|
|
27
|
+
it("prefers the active @earendil package over legacy local peers", () => {
|
|
28
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pet-pi-runtime-"));
|
|
29
|
+
roots.push(root);
|
|
30
|
+
const current = packageEntrypoint(root, "@earendil-works", "pi-coding-agent");
|
|
31
|
+
packageEntrypoint(root, "@mariozechner", "pi-coding-agent");
|
|
32
|
+
|
|
33
|
+
expect(resolvePiRuntimeModulePath(path.join(root, "pi-cli.js"))).toBe(fs.realpathSync(current));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("retains the legacy package as a compatibility fallback", () => {
|
|
37
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pet-pi-runtime-"));
|
|
38
|
+
roots.push(root);
|
|
39
|
+
const legacy = packageEntrypoint(root, "@mariozechner", "pi-coding-agent");
|
|
40
|
+
|
|
41
|
+
expect(resolvePiRuntimeModulePath(path.join(root, "pi-cli.js"))).toBe(fs.realpathSync(legacy));
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import type * as LegacyPiApi from "@mariozechner/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
export type PiRuntimeApi = typeof LegacyPiApi;
|
|
8
|
+
|
|
9
|
+
const CURRENT_PI_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
10
|
+
const LEGACY_PI_PACKAGE = "@mariozechner/pi-coding-agent";
|
|
11
|
+
let runtimeApiPromise: Promise<PiRuntimeApi> | undefined;
|
|
12
|
+
|
|
13
|
+
function requireRoots(fromFile?: string): string[] {
|
|
14
|
+
const roots = [fromFile, process.argv[1], __filename]
|
|
15
|
+
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
|
16
|
+
.map((value) => path.resolve(value));
|
|
17
|
+
return Array.from(new Set(roots));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function packageExportPath(packageDir: string, expectedName: string): string | undefined {
|
|
21
|
+
try {
|
|
22
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf-8"));
|
|
23
|
+
if (manifest.name !== expectedName) return undefined;
|
|
24
|
+
const rootExport = manifest.exports?.["."] ?? manifest.exports;
|
|
25
|
+
const relativeEntrypoint = typeof rootExport === "string"
|
|
26
|
+
? rootExport
|
|
27
|
+
: rootExport?.import ?? rootExport?.default ?? rootExport?.require ?? manifest.module ?? manifest.main;
|
|
28
|
+
return typeof relativeEntrypoint === "string"
|
|
29
|
+
? path.resolve(packageDir, relativeEntrypoint)
|
|
30
|
+
: undefined;
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveImportOnlyPackage(packageName: string, fromFile: string): string | undefined {
|
|
37
|
+
let directory = path.dirname(fromFile);
|
|
38
|
+
while (true) {
|
|
39
|
+
const ownPackage = packageExportPath(directory, packageName);
|
|
40
|
+
if (ownPackage) return ownPackage;
|
|
41
|
+
const dependencyPackage = packageExportPath(path.join(directory, "node_modules", packageName), packageName);
|
|
42
|
+
if (dependencyPackage) return dependencyPackage;
|
|
43
|
+
const parent = path.dirname(directory);
|
|
44
|
+
if (parent === directory) return undefined;
|
|
45
|
+
directory = parent;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Resolve package exports from the active Pi executable before local peer fallbacks. */
|
|
50
|
+
export function resolvePiRuntimeModulePath(fromFile?: string): string {
|
|
51
|
+
const roots = requireRoots(fromFile);
|
|
52
|
+
for (const packageName of [CURRENT_PI_PACKAGE, LEGACY_PI_PACKAGE]) {
|
|
53
|
+
for (const root of roots) {
|
|
54
|
+
try {
|
|
55
|
+
return createRequire(root).resolve(packageName);
|
|
56
|
+
} catch {
|
|
57
|
+
const importOnlyPath = resolveImportOnlyPackage(packageName, root);
|
|
58
|
+
if (importOnlyPath) return importOnlyPath;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
throw new Error("Could not resolve the active Pi coding-agent API.");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function importPiRuntimeApi(): Promise<PiRuntimeApi> {
|
|
66
|
+
const modulePath = resolvePiRuntimeModulePath();
|
|
67
|
+
return await import(pathToFileURL(modulePath).href) as PiRuntimeApi;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Use the SDK/configuration classes belonging to the running Pi process.
|
|
72
|
+
* This prevents a package-local legacy peer from replacing current resolver,
|
|
73
|
+
* trust, loader, or session behavior.
|
|
74
|
+
*/
|
|
75
|
+
export function loadPiRuntimeApi(): Promise<PiRuntimeApi> {
|
|
76
|
+
runtimeApiPromise ??= importPiRuntimeApi();
|
|
77
|
+
return runtimeApiPromise;
|
|
78
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a Google-compatible string enum schema using this package's TypeBox
|
|
5
|
+
* instance. The upstream `@mariozechner/pi-ai` helper can resolve to a
|
|
6
|
+
* different TypeBox instance in this package, which makes `Type.Optional()`
|
|
7
|
+
* reject its `TUnsafe` type at compile time.
|
|
8
|
+
*/
|
|
9
|
+
export function StringEnum<T extends readonly string[]>(
|
|
10
|
+
values: T,
|
|
11
|
+
options?: { description?: string; default?: T[number] }
|
|
12
|
+
) {
|
|
13
|
+
return Type.Unsafe<T[number]>({
|
|
14
|
+
type: "string",
|
|
15
|
+
enum: values,
|
|
16
|
+
...(options?.description && { description: options.description }),
|
|
17
|
+
...(options?.default && { default: options.default }),
|
|
18
|
+
});
|
|
19
|
+
}
|