@pi-harness/pi-harness 0.1.1

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 (185) hide show
  1. package/README.md +161 -0
  2. package/apps/web/dist/assets/index-C4tLweAB.js +20 -0
  3. package/apps/web/dist/assets/index-DPgRbQ4_.css +1 -0
  4. package/apps/web/dist/index.html +14 -0
  5. package/apps/web/index.html +13 -0
  6. package/apps/web/package.json +24 -0
  7. package/apps/web/profile/cordis.yml +44 -0
  8. package/apps/web/server-dist/bin.d.ts +3 -0
  9. package/apps/web/server-dist/bin.d.ts.map +1 -0
  10. package/apps/web/server-dist/bin.js +67 -0
  11. package/apps/web/server-dist/bin.js.map +1 -0
  12. package/apps/web/src/command-palette.ts +84 -0
  13. package/apps/web/src/file-diff.ts +42 -0
  14. package/apps/web/src/main.ts +12 -0
  15. package/apps/web/src/runtime.css +4 -0
  16. package/apps/web/src/server/bin.ts +64 -0
  17. package/apps/web/src/style.css +7 -0
  18. package/apps/web/tsconfig.build.json +1 -0
  19. package/apps/web/tsconfig.json +1 -0
  20. package/package.json +48 -0
  21. package/packages/api-gateway/dist/index.d.ts +8 -0
  22. package/packages/api-gateway/dist/index.d.ts.map +1 -0
  23. package/packages/api-gateway/dist/index.js +582 -0
  24. package/packages/api-gateway/dist/index.js.map +1 -0
  25. package/packages/api-gateway/package.json +29 -0
  26. package/packages/api-gateway/src/index.ts +577 -0
  27. package/packages/api-gateway/test/index.test.ts +327 -0
  28. package/packages/api-gateway/tsconfig.build.json +1 -0
  29. package/packages/api-gateway/tsconfig.json +1 -0
  30. package/packages/bundle-web-app/cordis.patch.yml +12 -0
  31. package/packages/bundle-web-app/dist/index.d.ts +12 -0
  32. package/packages/bundle-web-app/dist/index.d.ts.map +1 -0
  33. package/packages/bundle-web-app/dist/index.js +63 -0
  34. package/packages/bundle-web-app/dist/index.js.map +1 -0
  35. package/packages/bundle-web-app/package.json +30 -0
  36. package/packages/bundle-web-app/src/index.ts +69 -0
  37. package/packages/bundle-web-app/test/index.test.ts +29 -0
  38. package/packages/bundle-web-app/tsconfig.build.json +1 -0
  39. package/packages/bundle-web-app/tsconfig.json +1 -0
  40. package/packages/cli/dist/args.d.ts +16 -0
  41. package/packages/cli/dist/args.d.ts.map +1 -0
  42. package/packages/cli/dist/args.js +53 -0
  43. package/packages/cli/dist/args.js.map +1 -0
  44. package/packages/cli/dist/bin.d.ts +3 -0
  45. package/packages/cli/dist/bin.d.ts.map +1 -0
  46. package/packages/cli/dist/bin.js +40 -0
  47. package/packages/cli/dist/bin.js.map +1 -0
  48. package/packages/cli/dist/main.d.ts +14 -0
  49. package/packages/cli/dist/main.d.ts.map +1 -0
  50. package/packages/cli/dist/main.js +148 -0
  51. package/packages/cli/dist/main.js.map +1 -0
  52. package/packages/cli/dist/node-stdio.d.ts +10 -0
  53. package/packages/cli/dist/node-stdio.d.ts.map +1 -0
  54. package/packages/cli/dist/node-stdio.js +34 -0
  55. package/packages/cli/dist/node-stdio.js.map +1 -0
  56. package/packages/cli/dist/relaunch.d.ts +5 -0
  57. package/packages/cli/dist/relaunch.d.ts.map +1 -0
  58. package/packages/cli/dist/relaunch.js +52 -0
  59. package/packages/cli/dist/relaunch.js.map +1 -0
  60. package/packages/cli/package.json +25 -0
  61. package/packages/cli/src/args.ts +50 -0
  62. package/packages/cli/src/bin.ts +41 -0
  63. package/packages/cli/src/main.ts +155 -0
  64. package/packages/cli/src/node-stdio.ts +40 -0
  65. package/packages/cli/src/relaunch.ts +48 -0
  66. package/packages/cli/test/args.test.ts +28 -0
  67. package/packages/cli/test/cli.test.ts +153 -0
  68. package/packages/cli/test/relaunch.test.ts +29 -0
  69. package/packages/cli/tsconfig.build.json +11 -0
  70. package/packages/cli/tsconfig.json +10 -0
  71. package/packages/client-web/dist/control-room.d.ts +102 -0
  72. package/packages/client-web/dist/control-room.d.ts.map +1 -0
  73. package/packages/client-web/dist/control-room.js +12 -0
  74. package/packages/client-web/dist/control-room.js.map +1 -0
  75. package/packages/client-web/dist/design-contract.d.ts +85 -0
  76. package/packages/client-web/dist/design-contract.d.ts.map +1 -0
  77. package/packages/client-web/dist/design-contract.js +99 -0
  78. package/packages/client-web/dist/design-contract.js.map +1 -0
  79. package/packages/client-web/dist/index.d.ts +17 -0
  80. package/packages/client-web/dist/index.d.ts.map +1 -0
  81. package/packages/client-web/dist/index.js +19 -0
  82. package/packages/client-web/dist/index.js.map +1 -0
  83. package/packages/client-web/dist/react-room.d.ts +6 -0
  84. package/packages/client-web/dist/react-room.d.ts.map +1 -0
  85. package/packages/client-web/dist/react-room.js +103 -0
  86. package/packages/client-web/dist/react-room.js.map +1 -0
  87. package/packages/client-web/package.json +29 -0
  88. package/packages/client-web/src/control-room.ts +10 -0
  89. package/packages/client-web/src/design-contract.ts +112 -0
  90. package/packages/client-web/src/index.ts +21 -0
  91. package/packages/client-web/src/react-room.tsx +78 -0
  92. package/packages/client-web/test/design-contract.test.ts +24 -0
  93. package/packages/client-web/tsconfig.build.json +1 -0
  94. package/packages/client-web/tsconfig.json +1 -0
  95. package/packages/core/dist/boot.d.ts +13 -0
  96. package/packages/core/dist/boot.d.ts.map +1 -0
  97. package/packages/core/dist/boot.js +96 -0
  98. package/packages/core/dist/boot.js.map +1 -0
  99. package/packages/core/dist/context.d.ts +3 -0
  100. package/packages/core/dist/context.d.ts.map +1 -0
  101. package/packages/core/dist/context.js +2 -0
  102. package/packages/core/dist/context.js.map +1 -0
  103. package/packages/core/dist/index.d.ts +7 -0
  104. package/packages/core/dist/index.d.ts.map +1 -0
  105. package/packages/core/dist/index.js +6 -0
  106. package/packages/core/dist/index.js.map +1 -0
  107. package/packages/core/dist/plugins/model.d.ts +8 -0
  108. package/packages/core/dist/plugins/model.d.ts.map +1 -0
  109. package/packages/core/dist/plugins/model.js +12 -0
  110. package/packages/core/dist/plugins/model.js.map +1 -0
  111. package/packages/core/dist/plugins/models.d.ts +16 -0
  112. package/packages/core/dist/plugins/models.d.ts.map +1 -0
  113. package/packages/core/dist/plugins/models.js +24 -0
  114. package/packages/core/dist/plugins/models.js.map +1 -0
  115. package/packages/core/dist/plugins/resources.d.ts +18 -0
  116. package/packages/core/dist/plugins/resources.d.ts.map +1 -0
  117. package/packages/core/dist/plugins/resources.js +48 -0
  118. package/packages/core/dist/plugins/resources.js.map +1 -0
  119. package/packages/core/dist/plugins/runtime.d.ts +14 -0
  120. package/packages/core/dist/plugins/runtime.d.ts.map +1 -0
  121. package/packages/core/dist/plugins/runtime.js +68 -0
  122. package/packages/core/dist/plugins/runtime.js.map +1 -0
  123. package/packages/core/dist/plugins/session.d.ts +15 -0
  124. package/packages/core/dist/plugins/session.d.ts.map +1 -0
  125. package/packages/core/dist/plugins/session.js +20 -0
  126. package/packages/core/dist/plugins/session.js.map +1 -0
  127. package/packages/core/dist/plugins/stdio.d.ts +8 -0
  128. package/packages/core/dist/plugins/stdio.d.ts.map +1 -0
  129. package/packages/core/dist/plugins/stdio.js +20 -0
  130. package/packages/core/dist/plugins/stdio.js.map +1 -0
  131. package/packages/core/dist/plugins/tools.d.ts +13 -0
  132. package/packages/core/dist/plugins/tools.d.ts.map +1 -0
  133. package/packages/core/dist/plugins/tools.js +17 -0
  134. package/packages/core/dist/plugins/tools.js.map +1 -0
  135. package/packages/core/dist/profile.d.ts +8 -0
  136. package/packages/core/dist/profile.d.ts.map +1 -0
  137. package/packages/core/dist/profile.js +28 -0
  138. package/packages/core/dist/profile.js.map +1 -0
  139. package/packages/core/dist/runtime.d.ts +16 -0
  140. package/packages/core/dist/runtime.d.ts.map +1 -0
  141. package/packages/core/dist/runtime.js +33 -0
  142. package/packages/core/dist/runtime.js.map +1 -0
  143. package/packages/core/dist/services.d.ts +63 -0
  144. package/packages/core/dist/services.d.ts.map +1 -0
  145. package/packages/core/dist/services.js +39 -0
  146. package/packages/core/dist/services.js.map +1 -0
  147. package/packages/core/dist/stdio.d.ts +25 -0
  148. package/packages/core/dist/stdio.d.ts.map +1 -0
  149. package/packages/core/dist/stdio.js +74 -0
  150. package/packages/core/dist/stdio.js.map +1 -0
  151. package/packages/core/package.json +44 -0
  152. package/packages/core/profiles/default/cordis.yml +35 -0
  153. package/packages/core/profiles/development/cordis.yml +47 -0
  154. package/packages/core/src/boot.ts +103 -0
  155. package/packages/core/src/context.ts +3 -0
  156. package/packages/core/src/index.ts +6 -0
  157. package/packages/core/src/plugins/model.ts +12 -0
  158. package/packages/core/src/plugins/models.ts +32 -0
  159. package/packages/core/src/plugins/resources.ts +57 -0
  160. package/packages/core/src/plugins/runtime.ts +73 -0
  161. package/packages/core/src/plugins/session.ts +27 -0
  162. package/packages/core/src/plugins/stdio.ts +20 -0
  163. package/packages/core/src/plugins/tools.ts +23 -0
  164. package/packages/core/src/profile.ts +31 -0
  165. package/packages/core/src/runtime.ts +39 -0
  166. package/packages/core/src/services.ts +105 -0
  167. package/packages/core/src/stdio.ts +88 -0
  168. package/packages/core/test/boot.test.ts +86 -0
  169. package/packages/core/test/profile.test.ts +39 -0
  170. package/packages/core/test/profiles.integration.test.ts +93 -0
  171. package/packages/core/test/runtime-fixture.ts +31 -0
  172. package/packages/core/test/runtime.test.ts +54 -0
  173. package/packages/core/test/services.test.ts +131 -0
  174. package/packages/core/test/stdio.test.ts +87 -0
  175. package/packages/core/tsconfig.build.json +10 -0
  176. package/packages/core/tsconfig.json +10 -0
  177. package/packages/host-webserver/dist/index.d.ts +29 -0
  178. package/packages/host-webserver/dist/index.d.ts.map +1 -0
  179. package/packages/host-webserver/dist/index.js +73 -0
  180. package/packages/host-webserver/dist/index.js.map +1 -0
  181. package/packages/host-webserver/package.json +27 -0
  182. package/packages/host-webserver/src/index.ts +97 -0
  183. package/packages/host-webserver/test/index.test.ts +43 -0
  184. package/packages/host-webserver/tsconfig.build.json +1 -0
  185. package/packages/host-webserver/tsconfig.json +1 -0
@@ -0,0 +1,88 @@
1
+ import type { Context } from "@deepseek-ai/cordis";
2
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
3
+ import type { PiHarnessLaunch, PiRuntimeService } from "./services.js";
4
+
5
+ export interface PiHarnessStdio {
6
+ readPrompt(): Promise<string>;
7
+ writeOutput(text: string): void;
8
+ writeError(text: string): void;
9
+ }
10
+
11
+ export interface PiHarnessApplication {
12
+ run(): Promise<number>;
13
+ }
14
+
15
+ declare module "@deepseek-ai/cordis" {
16
+ interface Context {
17
+ piHarnessStdio: PiHarnessStdio;
18
+ piApplication: PiHarnessApplication;
19
+ }
20
+ }
21
+
22
+ export function provideStdioContext(context: Context, stdio: PiHarnessStdio): () => void {
23
+ return context.provide("piHarnessStdio", stdio);
24
+ }
25
+
26
+ function promptFromArgs(args: readonly string[]): string | undefined {
27
+ if (args.length === 0) return undefined;
28
+ if (args[0] === "--prompt") {
29
+ const prompt = args[1];
30
+ if (prompt === undefined) throw new Error("--prompt requires a value");
31
+ if (args.length > 2) throw new Error("--prompt accepts exactly one value");
32
+ return prompt;
33
+ }
34
+ if (args[0]?.startsWith("--prompt=")) return args[0].slice("--prompt=".length);
35
+ if (args.some((arg) => arg.startsWith("-"))) throw new Error(`Unknown stdio option: ${args.find((arg) => arg.startsWith("-")) ?? ""}`);
36
+ return args.join(" ");
37
+ }
38
+
39
+ export class StdioApplication implements PiHarnessApplication {
40
+ readonly #runtime: PiRuntimeService;
41
+ readonly #launch: PiHarnessLaunch;
42
+ readonly #stdio: PiHarnessStdio;
43
+ #running = false;
44
+
45
+ constructor(runtime: PiRuntimeService, launch: PiHarnessLaunch, stdio: PiHarnessStdio) {
46
+ this.#runtime = runtime;
47
+ this.#launch = launch;
48
+ this.#stdio = stdio;
49
+ }
50
+
51
+ writeSessionEvent(event: AgentSessionEvent): void {
52
+ if (!this.#running) return;
53
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") this.#stdio.writeOutput(event.assistantMessageEvent.delta);
54
+ }
55
+
56
+ async run(): Promise<number> {
57
+ if (this.#running) throw new Error("stdio application is already running");
58
+ this.#running = true;
59
+ try {
60
+ let prompt: string;
61
+ try {
62
+ prompt = promptFromArgs(this.#launch.args) ?? await this.#stdio.readPrompt();
63
+ } catch (error) {
64
+ this.#stdio.writeError(`${error instanceof Error ? error.message : String(error)}\n`);
65
+ return 2;
66
+ }
67
+ if (prompt.trim().length === 0) {
68
+ this.#stdio.writeError("Prompt is empty\n");
69
+ return 2;
70
+ }
71
+ try {
72
+ await this.#runtime.prompt(prompt);
73
+ } catch (error) {
74
+ this.#stdio.writeError(`${error instanceof Error ? error.message : String(error)}\n`);
75
+ return 1;
76
+ }
77
+ const lastMessage = this.#runtime.session.messages.at(-1);
78
+ if (lastMessage?.role === "assistant" && (lastMessage.stopReason === "error" || lastMessage.stopReason === "aborted")) {
79
+ this.#stdio.writeError(`${lastMessage.errorMessage ?? `Request ${lastMessage.stopReason}`}\n`);
80
+ return 1;
81
+ }
82
+ this.#stdio.writeOutput("\n");
83
+ return 0;
84
+ } finally {
85
+ this.#running = false;
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,86 @@
1
+ import { mkdtemp, readFile, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { afterEach, describe, expect, test } from "vitest";
6
+ import { bootHarness, type BootedHarness } from "../src/boot.js";
7
+
8
+ const booted: BootedHarness[] = [];
9
+
10
+ afterEach(async () => {
11
+ await Promise.all(booted.splice(0).map(async (harness) => harness.dispose()));
12
+ });
13
+
14
+ async function createProfile(entries: unknown[]): Promise<{ directory: string; profilePath: string }> {
15
+ const directory = await mkdtemp(join(tmpdir(), "pi-harness-boot-"));
16
+ const profilePath = join(directory, "cordis.yml");
17
+ await writeFile(profilePath, JSON.stringify(entries), "utf8");
18
+ return { directory, profilePath };
19
+ }
20
+
21
+ async function createPlugin(directory: string, name: string, source: string): Promise<string> {
22
+ const path = join(directory, `${name}.mjs`);
23
+ await writeFile(path, source, "utf8");
24
+ return pathToFileURL(path).href;
25
+ }
26
+
27
+ describe("bootHarness", () => {
28
+ test("loads a profile and activates its plugin", async () => {
29
+ const profile = await createProfile([]);
30
+ const plugin = await createPlugin(profile.directory, "provider", `export default function provider(ctx, config) { ctx.provide("fixtureValue", config.value); }`);
31
+ await writeFile(profile.profilePath, JSON.stringify([{ name: plugin, config: { value: "active" } }]), "utf8");
32
+
33
+ const harness = await bootHarness({ configPath: profile.profilePath });
34
+ booted.push(harness);
35
+
36
+ expect(harness.context.get("fixtureValue")).toBe("active");
37
+ });
38
+
39
+ test("fails loudly when a plugin module cannot be resolved", async () => {
40
+ const profile = await createProfile([{ name: "./missing-plugin.mjs" }]);
41
+
42
+ await expect(bootHarness({ configPath: profile.profilePath })).rejects.toThrow(/missing-plugin\.mjs/);
43
+ });
44
+
45
+ test("reports plugins left pending by unresolved injections", async () => {
46
+ const profile = await createProfile([]);
47
+ const plugin = await createPlugin(profile.directory, "consumer", `export default { inject: ["serviceThatDoesNotExist"], apply() {} };`);
48
+ await writeFile(profile.profilePath, JSON.stringify([{ name: plugin }]), "utf8");
49
+
50
+ await expect(bootHarness({ configPath: profile.profilePath })).rejects.toThrow(/serviceThatDoesNotExist/);
51
+ });
52
+
53
+ test("disposes already-active plugins when a later activation fails", async () => {
54
+ const profile = await createProfile([]);
55
+ const markerPath = join(profile.directory, "lifecycle.txt");
56
+ const owner = await createPlugin(profile.directory, "owner", `import { appendFileSync, writeFileSync } from "node:fs"; export default function owner(ctx, config) { writeFileSync(config.markerPath, "started"); ctx.provide("fixtureOwnerReady", true); ctx.effect(() => () => appendFileSync(config.markerPath, ":disposed")); }`);
57
+ const failure = await createPlugin(profile.directory, "failure", `export default { inject: ["fixtureOwnerReady"], apply() { throw new Error("fixture activation failed"); } };`);
58
+ await writeFile(profile.profilePath, JSON.stringify([{ name: owner, config: { markerPath } }, { name: failure }]), "utf8");
59
+
60
+ await expect(bootHarness({ configPath: profile.profilePath })).rejects.toThrow(/fixture activation failed/);
61
+ await expect(readFile(markerPath, "utf8")).resolves.toBe("started:disposed");
62
+ });
63
+
64
+ test("does not rewrite a profile when a nested group rolls back", async () => {
65
+ const profile = await createProfile([]);
66
+ const active = await createPlugin(profile.directory, "active", `export default function active(ctx) { ctx.provide("fixtureNestedReady", true); }`);
67
+ const failure = await createPlugin(profile.directory, "nested-failure", `export default { inject: ["fixtureNestedReady"], apply() { throw new Error("nested activation failed"); } };`);
68
+ const source = JSON.stringify([{ id: "fixture-group", name: "cordis:group", group: true, config: [{ id: "active", name: active }, { id: "nested-failure", name: failure }] }]);
69
+ await writeFile(profile.profilePath, source, "utf8");
70
+
71
+ await expect(bootHarness({ configPath: profile.profilePath })).rejects.toThrow(/nested activation failed/);
72
+
73
+ await expect(readFile(profile.profilePath, "utf8")).resolves.toBe(source);
74
+ });
75
+
76
+ test("forwards Cordis full-reload requests to the host", async () => {
77
+ const profile = await createProfile([]);
78
+ let reloads = 0;
79
+ const harness = await bootHarness({ configPath: profile.profilePath, onFullReload() { reloads += 1; } });
80
+ booted.push(harness);
81
+
82
+ harness.context.loader.exit();
83
+
84
+ expect(reloads).toBe(1);
85
+ });
86
+ });
@@ -0,0 +1,39 @@
1
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { describe, expect, test } from "vitest";
5
+ import { resolveProfileConfig } from "../src/profile.js";
6
+
7
+ async function createProfiles(): Promise<string> {
8
+ const root = await mkdtemp(join(tmpdir(), "pi-harness-profiles-"));
9
+ await mkdir(join(root, "default"));
10
+ await writeFile(join(root, "default", "cordis.yml"), "[]\n", "utf8");
11
+ return root;
12
+ }
13
+
14
+ describe("resolveProfileConfig", () => {
15
+ test("resolves the default built-in profile", async () => {
16
+ const profilesDir = await createProfiles();
17
+
18
+ await expect(resolveProfileConfig({ profilesDir })).resolves.toBe(join(profilesDir, "default", "cordis.yml"));
19
+ });
20
+
21
+ test("resolves an explicit config path from the invoking directory", async () => {
22
+ const cwd = await mkdtemp(join(tmpdir(), "pi-harness-config-"));
23
+ await writeFile(join(cwd, "custom.yml"), "[]\n", "utf8");
24
+
25
+ await expect(resolveProfileConfig({ configPath: "custom.yml", cwd })).resolves.toBe(resolve(cwd, "custom.yml"));
26
+ });
27
+
28
+ test("rejects profile names that can escape the profiles directory", async () => {
29
+ const profilesDir = await createProfiles();
30
+
31
+ await expect(resolveProfileConfig({ profile: "../outside", profilesDir })).rejects.toThrow(/invalid profile name/i);
32
+ });
33
+
34
+ test("rejects a missing profile with its resolved path", async () => {
35
+ const profilesDir = await createProfiles();
36
+
37
+ await expect(resolveProfileConfig({ profile: "missing", profilesDir })).rejects.toThrow(join(profilesDir, "missing", "cordis.yml"));
38
+ });
39
+ });
@@ -0,0 +1,93 @@
1
+ import { mkdtemp, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, test } from "vitest";
5
+ import { bootHarness, type BootedHarness } from "../src/boot.js";
6
+ import { resolveProfileConfig } from "../src/profile.js";
7
+ import { provideLaunchContext } from "../src/services.js";
8
+ import { provideStdioContext } from "../src/stdio.js";
9
+
10
+ const booted: BootedHarness[] = [];
11
+
12
+ afterEach(async () => {
13
+ await Promise.all(booted.splice(0).map(async (harness) => harness.dispose()));
14
+ });
15
+
16
+ async function bootProfile(profile: string): Promise<{ harness: BootedHarness; cwd: string }> {
17
+ const cwd = await mkdtemp(join(tmpdir(), "pi-harness-profile-cwd-"));
18
+ const agentDir = await mkdtemp(join(tmpdir(), "pi-harness-profile-agent-"));
19
+ const configPath = await resolveProfileConfig({ profile });
20
+ const harness = await bootHarness({
21
+ configPath,
22
+ prepare(context) {
23
+ provideLaunchContext(context, { cwd, agentDir, args: [], requestExit() {} });
24
+ provideStdioContext(context, { readPrompt() { return Promise.resolve(""); }, writeOutput() {}, writeError() {} });
25
+ },
26
+ });
27
+ booted.push(harness);
28
+ return { harness, cwd };
29
+ }
30
+
31
+ describe("packaged profiles", () => {
32
+ test("boots the default production profile without HMR", async () => {
33
+ const { harness } = await bootProfile("default");
34
+ const names = [...harness.context.loader.entries()].map((entry) => entry.options.name);
35
+
36
+ expect(harness.context.get("piModels")?.model.provider).toBe("deepseek");
37
+ expect(harness.context.get("piApplication")).toBeDefined();
38
+ expect(harness.context.get("hmr")).toBeUndefined();
39
+ expect(names).not.toContain("@deepseek-ai/cordis-plugin-hmr");
40
+ });
41
+
42
+ test("boots the development profile with logger, timer, and HMR plugins", async () => {
43
+ const { harness, cwd } = await bootProfile("development");
44
+ const entries = [...harness.context.loader.entries()];
45
+ const names = entries.map((entry) => entry.options.name);
46
+ const timer = entries.find((entry) => entry.options.name === "@deepseek-ai/cordis-plugin-timer");
47
+ const hmr = entries.find((entry) => entry.options.name === "@deepseek-ai/cordis-plugin-hmr");
48
+
49
+ expect(timer?.fiber?.ctx.get("timer")).toBeDefined();
50
+ expect(hmr?.fiber?.ctx.get("hmr")).toMatchObject({ baseDir: cwd });
51
+ expect(names).toEqual(expect.arrayContaining(["@deepseek-ai/cordis-plugin-logger-console", "@deepseek-ai/cordis-plugin-timer", "@deepseek-ai/cordis-plugin-hmr"]));
52
+ });
53
+
54
+ test("activates an external tool plugin before the runtime through Cordis injection", async () => {
55
+ const cwd = await mkdtemp(join(tmpdir(), "pi-harness-custom-cwd-"));
56
+ const agentDir = await mkdtemp(join(tmpdir(), "pi-harness-custom-agent-"));
57
+ const configPath = join(cwd, "cordis.yml");
58
+ const plugins = {
59
+ models: import.meta.resolve("@pi-harness/core/plugins/models"),
60
+ resources: import.meta.resolve("@pi-harness/core/plugins/resources"),
61
+ model: import.meta.resolve("@pi-harness/core/plugins/model"),
62
+ session: import.meta.resolve("@pi-harness/core/plugins/session"),
63
+ tools: import.meta.resolve("@pi-harness/core/plugins/tools"),
64
+ hello: import.meta.resolve("@pi-harness/plugin-hello"),
65
+ runtime: import.meta.resolve("@pi-harness/core/plugins/runtime"),
66
+ stdio: import.meta.resolve("@pi-harness/core/plugins/stdio"),
67
+ };
68
+ await writeFile(configPath, JSON.stringify([
69
+ { id: "models", name: plugins.models, config: { provider: "deepseek", model: "deepseek-v4-flash", refreshOnCreate: false } },
70
+ { id: "resources", name: plugins.resources, config: { noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true } },
71
+ { id: "model", name: plugins.model, config: {} },
72
+ { id: "session", name: plugins.session, config: { storage: "memory" } },
73
+ { id: "tools", name: plugins.tools, config: { names: [] } },
74
+ { id: "hello", name: plugins.hello, config: {} },
75
+ { id: "runtime", name: plugins.runtime, inject: ["piHelloTool"], config: { thinkingLevel: "medium" } },
76
+ { id: "stdio", name: plugins.stdio, config: {} },
77
+ ]), "utf8");
78
+ const harness = await bootHarness({
79
+ configPath,
80
+ prepare(context) {
81
+ provideLaunchContext(context, { cwd, agentDir, args: [], requestExit() {} });
82
+ provideStdioContext(context, { readPrompt() { return Promise.resolve(""); }, writeOutput() {}, writeError() {} });
83
+ },
84
+ });
85
+ booted.push(harness);
86
+
87
+ expect(harness.context.get("piTools")?.snapshot().customTools.map((tool) => tool.name)).toEqual(["hello"]);
88
+ const sessionTools = harness.context.get("piRuntime")?.session.getAllTools();
89
+ expect(sessionTools).toHaveLength(1);
90
+ expect(sessionTools?.[0]?.name).toBe("hello");
91
+ expect(sessionTools?.[0]?.sourceInfo.source).toBe("sdk");
92
+ });
93
+ });
@@ -0,0 +1,31 @@
1
+ import { mkdtemp } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { Context } from "@deepseek-ai/cordis";
5
+ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
6
+ import { fauxProvider, type FauxProviderHandle, type FauxResponseStep } from "@earendil-works/pi-ai/providers/faux";
7
+ import { provideLaunchContext } from "../src/services.js";
8
+ import resourcesPlugin from "../src/plugins/resources.js";
9
+ import runtimePlugin from "../src/plugins/runtime.js";
10
+ import sessionPlugin from "../src/plugins/session.js";
11
+ import toolsPlugin from "../src/plugins/tools.js";
12
+
13
+ export async function createTestRuntimeContext(responses: FauxResponseStep[], toolNames: string[] = []): Promise<{ context: Context; faux: FauxProviderHandle }> {
14
+ const cwd = await mkdtemp(join(tmpdir(), "pi-harness-runtime-"));
15
+ const agentDir = await mkdtemp(join(tmpdir(), "pi-harness-runtime-agent-"));
16
+ const context = new Context();
17
+ provideLaunchContext(context, { cwd, agentDir, args: [], requestExit() {} });
18
+ const modelRuntime = await ModelRuntime.create({ refreshOnCreate: false, modelsPath: null });
19
+ const faux = fauxProvider({ provider: "pi-harness-test", models: [{ id: "deterministic" }] });
20
+ faux.setResponses(responses);
21
+ modelRuntime.registerNativeProvider(faux.provider);
22
+ const model = modelRuntime.getModel("pi-harness-test", "deterministic");
23
+ if (model === undefined) throw new Error("Faux model registration failed");
24
+ context.provide("piModelRuntime", { runtime: modelRuntime, provider: "pi-harness-test", model: "deterministic" });
25
+ await context.plugin(resourcesPlugin, { noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true });
26
+ context.provide("piModels", { runtime: modelRuntime, model });
27
+ await context.plugin(sessionPlugin, { storage: "memory" });
28
+ await context.plugin(toolsPlugin, { names: toolNames });
29
+ await context.plugin(runtimePlugin, { thinkingLevel: "off" });
30
+ return { context, faux };
31
+ }
@@ -0,0 +1,54 @@
1
+ import type { Context } from "@deepseek-ai/cordis";
2
+ import { fauxAssistantMessage } from "@earendil-works/pi-ai/providers/faux";
3
+ import { afterEach, describe, expect, test } from "vitest";
4
+ import { createTestRuntimeContext } from "./runtime-fixture.js";
5
+
6
+ const contexts: Context[] = [];
7
+
8
+ afterEach(async () => {
9
+ await Promise.all(contexts.splice(0).map(async (context) => context.fiber.dispose()));
10
+ });
11
+
12
+ async function createRuntimeContext(): Promise<{ context: Context; responseText: string[]; callCount: () => number }> {
13
+ const { context, faux } = await createTestRuntimeContext([fauxAssistantMessage("deterministic response")]);
14
+ contexts.push(context);
15
+ const responseText: string[] = [];
16
+ context.piRuntime.session.subscribe((event) => {
17
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") responseText.push(event.assistantMessageEvent.delta);
18
+ });
19
+ return { context, responseText, callCount: () => faux.state.callCount };
20
+ }
21
+
22
+ describe("Pi runtime plugin", () => {
23
+ test("completes a deterministic Pi agent run", async () => {
24
+ const { context, responseText, callCount } = await createRuntimeContext();
25
+
26
+ await context.piRuntime.prompt("respond once");
27
+
28
+ expect(responseText.join("")).toBe("deterministic response");
29
+ expect(callCount()).toBe(1);
30
+ });
31
+
32
+ test("disposes the Pi session with its Cordis fiber", async () => {
33
+ const { context } = await createRuntimeContext();
34
+ const runtime = context.piRuntime;
35
+
36
+ await context.fiber.dispose();
37
+
38
+ await expect(runtime.prompt("too late")).rejects.toThrow(/disposed/);
39
+ });
40
+
41
+ test("replaces the active session through the Pi session runtime", async () => {
42
+ const { context } = await createRuntimeContext();
43
+ const before = context.piRuntime.session.sessionId;
44
+
45
+ await context.piRuntime.sessionRuntime.newSession();
46
+
47
+ expect(context.piRuntime.session.sessionId).not.toBe(before);
48
+ expect(context.piRuntime.session.messages).toEqual([]);
49
+ });
50
+
51
+ test("fails activation when configured core tools are unknown to Pi", async () => {
52
+ await expect(createTestRuntimeContext([], ["not-a-pi-tool"])).rejects.toThrow(/not-a-pi-tool/);
53
+ });
54
+ });
@@ -0,0 +1,131 @@
1
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { Context } from "@deepseek-ai/cordis";
5
+ import { Type } from "@earendil-works/pi-ai";
6
+ import { defineTool } from "@earendil-works/pi-coding-agent";
7
+ import { afterEach, describe, expect, test } from "vitest";
8
+ import { PiToolRegistry, provideLaunchContext } from "../src/services.js";
9
+ import modelPlugin from "../src/plugins/model.js";
10
+ import modelsPlugin from "../src/plugins/models.js";
11
+ import resourcesPlugin from "../src/plugins/resources.js";
12
+ import sessionPlugin from "../src/plugins/session.js";
13
+ import toolsPlugin from "../src/plugins/tools.js";
14
+
15
+ const contexts: Context[] = [];
16
+
17
+ afterEach(async () => {
18
+ await Promise.all(contexts.splice(0).map(async (context) => context.fiber.dispose()));
19
+ });
20
+
21
+ async function createContext(): Promise<{ context: Context; cwd: string; agentDir: string }> {
22
+ const cwd = await mkdtemp(join(tmpdir(), "pi-harness-cwd-"));
23
+ const agentDir = await mkdtemp(join(tmpdir(), "pi-harness-agent-"));
24
+ const context = new Context();
25
+ contexts.push(context);
26
+ provideLaunchContext(context, { cwd, agentDir, args: [], requestExit() {} });
27
+ return { context, cwd, agentDir };
28
+ }
29
+
30
+ const modelConfig = { provider: "deepseek", model: "deepseek-v4-flash", refreshOnCreate: false } as const;
31
+ const isolatedResources = { noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true } as const;
32
+
33
+ describe("Pi domain plugins", () => {
34
+ test("fails activation when the selected model does not exist", async () => {
35
+ const { context } = await createContext();
36
+
37
+ await context.plugin(modelsPlugin, { provider: "missing-provider", model: "missing-model", refreshOnCreate: false });
38
+ await context.plugin(resourcesPlugin, isolatedResources);
39
+
40
+ await expect(context.plugin(modelPlugin)).rejects.toThrow(/missing-provider\/missing-model/);
41
+ });
42
+
43
+ test("activates a pending resources plugin after its models dependency", async () => {
44
+ const { context, cwd } = await createContext();
45
+ const pendingResources = context.plugin(resourcesPlugin, isolatedResources);
46
+
47
+ await context.plugin(modelsPlugin, modelConfig);
48
+ await pendingResources;
49
+
50
+ expect(context.get("piResources")?.cwd).toBe(cwd);
51
+ expect(context.get("piResources")?.modelRuntime).toBe(context.get("piModelRuntime")?.runtime);
52
+ });
53
+
54
+ test("isolates Pi model files under the configured agent directory", async () => {
55
+ const { context, agentDir } = await createContext();
56
+
57
+ await context.plugin(modelsPlugin, modelConfig);
58
+
59
+ const runtime = context.piModelRuntime.runtime as unknown as { modelsPath: string };
60
+ expect(runtime.modelsPath).toBe(join(agentDir, "models.json"));
61
+ });
62
+
63
+ test("selects models registered by Pi extensions after resource loading", async () => {
64
+ const { context, agentDir } = await createContext();
65
+ const extensionsDir = join(agentDir, "extensions");
66
+ await mkdir(extensionsDir, { recursive: true });
67
+ await writeFile(join(extensionsDir, "provider.ts"), `export default function (pi) { pi.registerProvider("extension-provider", { baseUrl: "https://example.invalid", apiKey: "test-key", api: "openai-completions", models: [{ id: "extension-model", name: "Extension Model", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 4096, maxTokens: 1024 }] }); }`, "utf8");
68
+
69
+ await context.plugin(modelsPlugin, { provider: "extension-provider", model: "extension-model", refreshOnCreate: false });
70
+ await context.plugin(resourcesPlugin, { ...isolatedResources, noExtensions: false });
71
+ await context.plugin(modelPlugin);
72
+
73
+ expect(context.piModels.model.id).toBe("extension-model");
74
+ expect(context.piModels.model.provider).toBe("extension-provider");
75
+ });
76
+
77
+ test("creates an in-memory session when configured", async () => {
78
+ const { context } = await createContext();
79
+
80
+ await context.plugin(sessionPlugin, { storage: "memory" });
81
+
82
+ expect(context.get("piSession")?.manager.isPersisted()).toBe(false);
83
+ });
84
+
85
+ test("creates a JSONL session in the configured directory", async () => {
86
+ const { context } = await createContext();
87
+ const directory = await mkdtemp(join(tmpdir(), "pi-harness-sessions-"));
88
+
89
+ await context.plugin(sessionPlugin, { storage: "jsonl", directory });
90
+
91
+ expect(context.get("piSession")?.manager.isPersisted()).toBe(true);
92
+ expect(context.get("piSession")?.manager.getSessionDir()).toBe(directory);
93
+ });
94
+
95
+ test("keeps the default JSONL session under the configured agent directory", async () => {
96
+ const { context, agentDir } = await createContext();
97
+
98
+ await context.plugin(sessionPlugin, { storage: "jsonl" });
99
+
100
+ expect(context.get("piSession")?.manager.getSessionDir()).toBe(join(agentDir, "sessions"));
101
+ });
102
+
103
+ test("contributes Pi's core tools through a lifecycle-owned registry", async () => {
104
+ const { context } = await createContext();
105
+
106
+ await context.plugin(toolsPlugin, { names: ["read", "bash", "edit", "write"] });
107
+
108
+ expect(context.get("piTools")?.snapshot()).toEqual({ names: ["read", "bash", "edit", "write"], customTools: [] });
109
+ });
110
+
111
+ test("rejects tool contributions while runtime owns a snapshot and accepts them after release", () => {
112
+ const tools = new PiToolRegistry();
113
+ const lateTool = defineTool({
114
+ name: "late",
115
+ label: "Late",
116
+ description: "A tool registered after runtime startup.",
117
+ parameters: Type.Object({}),
118
+ execute() {
119
+ return Promise.resolve({ content: [{ type: "text", text: "late" }], details: undefined });
120
+ },
121
+ });
122
+
123
+ const lease = tools.acquire();
124
+ expect(lease.names).toEqual([]);
125
+ expect(lease.customTools).toEqual([]);
126
+ expect(() => tools.register(lateTool)).toThrow(/leased/);
127
+
128
+ lease.release();
129
+ expect(() => tools.register(lateTool)).not.toThrow();
130
+ });
131
+ });
@@ -0,0 +1,87 @@
1
+ import type { Context } from "@deepseek-ai/cordis";
2
+ import { fauxAssistantMessage } from "@earendil-works/pi-ai/providers/faux";
3
+ import { afterEach, describe, expect, test } from "vitest";
4
+ import { provideStdioContext, type PiHarnessStdio } from "../src/stdio.js";
5
+ import stdioPlugin from "../src/plugins/stdio.js";
6
+ import { createTestRuntimeContext } from "./runtime-fixture.js";
7
+
8
+ const contexts: Context[] = [];
9
+
10
+ afterEach(async () => {
11
+ await Promise.all(contexts.splice(0).map(async (context) => context.fiber.dispose()));
12
+ });
13
+
14
+ function captureStdio(input: string): PiHarnessStdio & { output: string[]; errors: string[] } {
15
+ const output: string[] = [];
16
+ const errors: string[] = [];
17
+ return {
18
+ output,
19
+ errors,
20
+ readPrompt() {
21
+ return Promise.resolve(input);
22
+ },
23
+ writeOutput(text) {
24
+ output.push(text);
25
+ },
26
+ writeError(text) {
27
+ errors.push(text);
28
+ },
29
+ };
30
+ }
31
+
32
+ describe("stdio application plugin", () => {
33
+ test("reads one prompt and streams the assistant response", async () => {
34
+ const { context, faux } = await createTestRuntimeContext([fauxAssistantMessage("stdio response")]);
35
+ contexts.push(context);
36
+ const stdio = captureStdio("user prompt");
37
+ provideStdioContext(context, stdio);
38
+ await context.plugin(stdioPlugin);
39
+
40
+ const exitCode = await context.piApplication.run();
41
+
42
+ expect(exitCode).toBe(0);
43
+ expect(stdio.output.join("")).toBe("stdio response\n");
44
+ expect(stdio.errors).toEqual([]);
45
+ expect(faux.state.callCount).toBe(1);
46
+ expect(context.piRuntime.session.messages.some((message) => message.role === "user")).toBe(true);
47
+ });
48
+
49
+ test("returns a usage error without calling the model for an empty prompt", async () => {
50
+ const { context, faux } = await createTestRuntimeContext([fauxAssistantMessage("unused")]);
51
+ contexts.push(context);
52
+ const stdio = captureStdio(" ");
53
+ provideStdioContext(context, stdio);
54
+ await context.plugin(stdioPlugin);
55
+
56
+ const exitCode = await context.piApplication.run();
57
+
58
+ expect(exitCode).toBe(2);
59
+ expect(stdio.errors.join("")).toMatch(/prompt is empty/i);
60
+ expect(faux.state.callCount).toBe(0);
61
+ });
62
+
63
+ test("reports a settled provider error as a failed application run", async () => {
64
+ const { context } = await createTestRuntimeContext([fauxAssistantMessage("", { stopReason: "error", errorMessage: "provider failed" })]);
65
+ contexts.push(context);
66
+ const stdio = captureStdio("trigger error");
67
+ provideStdioContext(context, stdio);
68
+ await context.plugin(stdioPlugin);
69
+
70
+ const exitCode = await context.piApplication.run();
71
+
72
+ expect(exitCode).toBe(1);
73
+ expect(stdio.errors.join("")).toContain("provider failed");
74
+ });
75
+
76
+ test("reports non-error resource diagnostics instead of dropping them", async () => {
77
+ const { context } = await createTestRuntimeContext([]);
78
+ contexts.push(context);
79
+ (context.piResources.diagnostics as unknown as Array<{ type: "warning"; message: string }>).push({ type: "warning", message: "extension warning" });
80
+ const stdio = captureStdio("");
81
+ provideStdioContext(context, stdio);
82
+
83
+ await context.plugin(stdioPlugin);
84
+
85
+ expect(stdio.errors).toEqual(["Resource warning: extension warning\n"]);
86
+ });
87
+ });
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.build.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": [
8
+ "src/**/*.ts"
9
+ ]
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "."
5
+ },
6
+ "include": [
7
+ "src/**/*.ts",
8
+ "test/**/*.ts"
9
+ ]
10
+ }
@@ -0,0 +1,29 @@
1
+ import { type IncomingMessage, type ServerResponse } from "node:http";
2
+ import type { Context } from "@deepseek-ai/cordis";
3
+ export interface WebRoute {
4
+ readonly path: string;
5
+ readonly handler: (request: IncomingMessage, response: ServerResponse) => void | Promise<void>;
6
+ }
7
+ export interface WebServerConfig {
8
+ readonly host?: string;
9
+ readonly port?: number;
10
+ }
11
+ export interface WebServer {
12
+ readonly host: string;
13
+ readonly port: number;
14
+ readonly url: string;
15
+ register(route: WebRoute): () => void;
16
+ registerFallback(handler: WebRoute["handler"]): () => void;
17
+ close(): Promise<void>;
18
+ }
19
+ declare module "@deepseek-ai/cordis" {
20
+ interface Context {
21
+ webServer: WebServer;
22
+ }
23
+ }
24
+ declare const _default: {
25
+ name: string;
26
+ apply(context: Context, config: WebServerConfig): Promise<void>;
27
+ };
28
+ export default _default;
29
+ //# sourceMappingURL=index.d.ts.map