@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,327 @@
1
+ import { mkdtemp, writeFile } from "node:fs/promises";
2
+ import { execFile as execFileCallback } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { Context } from "@deepseek-ai/cordis";
7
+ import { afterEach, describe, expect, test } from "vitest";
8
+ import webServerPlugin from "@pi-harness/host-webserver";
9
+ import apiPlugin from "../src/index.js";
10
+
11
+ const contexts: Context[] = [];
12
+ const execFile = promisify(execFileCallback);
13
+
14
+ afterEach(async () => {
15
+ await Promise.all(contexts.splice(0).map(async (context) => context.fiber.dispose()));
16
+ });
17
+
18
+ describe("API gateway plugin", () => {
19
+ test("serializes concurrent prompts and validates input", async () => {
20
+ const context = new Context();
21
+ contexts.push(context);
22
+ await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
23
+ const session = { messages: [], subscribe: () => () => {} };
24
+ let promptStarted = false;
25
+ let releasePrompt: (() => void) | undefined;
26
+ const runtime = {
27
+ session,
28
+ prompt: () => {
29
+ promptStarted = true;
30
+ return new Promise<void>((resolve) => { releasePrompt = resolve; });
31
+ },
32
+ };
33
+ context.provide("piRuntime", runtime as never);
34
+ context.provide("piModels", { model: { provider: "test", id: "model" } } as never);
35
+ context.provide("piHarnessLaunch", { cwd: "/tmp", agentDir: "/tmp/agent", args: [], requestExit() {} });
36
+ await context.plugin(apiPlugin);
37
+
38
+ const first = fetch(context.webServer.url + "/api/prompt", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: "first" }) });
39
+ while (!promptStarted) await new Promise((resolve) => setTimeout(resolve, 1));
40
+ await expect(fetch(context.webServer.url + "/api/prompt", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: "second" }) })).resolves.toMatchObject({ status: 409 });
41
+ releasePrompt?.();
42
+ await expect(first).resolves.toMatchObject({ status: 200 });
43
+ await expect(fetch(context.webServer.url + "/api/prompt", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: " " }) })).resolves.toMatchObject({ status: 400 });
44
+ });
45
+
46
+ test("returns the live session messages and trajectory events", async () => {
47
+ const context = new Context();
48
+ contexts.push(context);
49
+ await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
50
+ type Listener = (event: { type: string; [key: string]: unknown }) => void;
51
+ const listeners = new Set<Listener>();
52
+ const session = {
53
+ sessionId: "session-test",
54
+ sessionFile: "/tmp/session-test.jsonl",
55
+ messages: [{ role: "user", content: "hello", timestamp: 1 }],
56
+ subscribe(listener: Listener) {
57
+ listeners.add(listener);
58
+ return () => listeners.delete(listener);
59
+ },
60
+ };
61
+ const runtime = { session, prompt: () => {
62
+ listeners.forEach((listener) => listener({ type: "tool_execution_start", toolName: "read", toolCallId: "call-1", args: { path: "README.md" } }));
63
+ session.messages.push({ role: "assistant", content: [{ type: "text", text: "done" }], timestamp: 2 });
64
+ } };
65
+ context.provide("piRuntime", runtime as never);
66
+ context.provide("piModels", { model: { provider: "test", id: "model" } } as never);
67
+ context.provide("piHarnessLaunch", { cwd: "/tmp", agentDir: "/tmp/agent", args: [], requestExit() {} });
68
+ await context.plugin(apiPlugin);
69
+
70
+ await fetch(context.webServer.url + "/api/prompt", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: "hello" }) });
71
+ const response = await fetch(context.webServer.url + "/api/session");
72
+ expect(response.status).toBe(200);
73
+ await expect(response.json()).resolves.toMatchObject({
74
+ sessionId: "session-test",
75
+ messages: [{ role: "user", content: "hello", timestamp: 1 }, { role: "assistant" }],
76
+ events: [{ type: "tool_execution_start", toolName: "read" }],
77
+ });
78
+ });
79
+
80
+ test("opens an event stream with the current trajectory snapshot", async () => {
81
+ const context = new Context();
82
+ contexts.push(context);
83
+ await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
84
+ type Listener = (event: { type: string; [key: string]: unknown }) => void;
85
+ const listeners = new Set<Listener>();
86
+ const session = {
87
+ sessionId: "stream-session",
88
+ sessionFile: undefined,
89
+ messages: [],
90
+ subscribe(listener: Listener) {
91
+ listeners.add(listener);
92
+ return () => listeners.delete(listener);
93
+ },
94
+ };
95
+ context.provide("piRuntime", { session, prompt: () => Promise.resolve() } as never);
96
+ context.provide("piModels", { model: { provider: "test", id: "model" } } as never);
97
+ context.provide("piHarnessLaunch", { cwd: "/tmp", agentDir: "/tmp/agent", args: [], requestExit() {} });
98
+ await context.plugin(apiPlugin);
99
+
100
+ const response = await fetch(context.webServer.url + "/api/events");
101
+ expect(response.headers.get("content-type")).toContain("text/event-stream");
102
+ const reader = response.body?.getReader();
103
+ const first = await reader?.read();
104
+ const text = new TextDecoder().decode(first?.value);
105
+ expect(text).toContain('"type":"snapshot"');
106
+ expect(text).toContain('"sessionId":"stream-session"');
107
+ const next = reader?.read();
108
+ listeners.forEach((listener) => listener({ type: "tool_execution_start", toolName: "read" }));
109
+ const second = await next;
110
+ expect(new TextDecoder().decode(second?.value)).toContain('"type":"event"');
111
+ await reader?.cancel();
112
+ });
113
+
114
+ test("creates a new session through the live AgentSession", async () => {
115
+ const context = new Context();
116
+ contexts.push(context);
117
+ await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
118
+ let sessionId = "old-session";
119
+ let resetCount = 0;
120
+ const session = {
121
+ get sessionId() { return sessionId; },
122
+ sessionFile: undefined,
123
+ messages: [{ role: "user", content: "old" }],
124
+ isStreaming: false,
125
+ sessionManager: { newSession() { sessionId = "new-session"; resetCount += 1; }, getEntries: () => [] },
126
+ agent: { state: { messages: [{ role: "user", content: "old" }] } },
127
+ subscribe: () => () => {},
128
+ };
129
+ context.provide("piRuntime", { session, prompt: () => Promise.resolve() } as never);
130
+ context.provide("piModels", { model: { provider: "test", id: "model" } } as never);
131
+ context.provide("piHarnessLaunch", { cwd: "/tmp", agentDir: "/tmp/agent", args: [], requestExit() {} });
132
+ await context.plugin(apiPlugin);
133
+
134
+ const sessions = await fetch(context.webServer.url + "/api/sessions");
135
+ expect(sessions.status).toBe(200);
136
+ await expect(sessions.json()).resolves.toEqual({ items: [] });
137
+ const response = await fetch(context.webServer.url + "/api/session/new", { method: "POST" });
138
+ expect(response.status).toBe(200);
139
+ await expect(response.json()).resolves.toMatchObject({ sessionId: "new-session", messages: [] });
140
+ expect(resetCount).toBe(1);
141
+ });
142
+
143
+ test("opens a persisted session from the session list", async () => {
144
+ const context = new Context();
145
+ contexts.push(context);
146
+ await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
147
+ const directory = await mkdtemp(join(tmpdir(), "pi-harness-api-sessions-"));
148
+ const path = join(directory, "2026-08-30T00-00-00-000Z_target.jsonl");
149
+ await writeFile(path, `${JSON.stringify({ type: "session", version: 3, id: "target-session", timestamp: new Date().toISOString(), cwd: "/tmp" })}\n${JSON.stringify({ type: "message", id: "message-1", parentId: null, timestamp: new Date().toISOString(), message: { role: "assistant", content: [{ type: "text", text: "saved" }], provider: "test", model: "model", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: "stop", timestamp: Date.now() } })}\n`, "utf8");
150
+ let openedPath = "";
151
+ const session = {
152
+ sessionId: "active-session",
153
+ sessionFile: "/tmp/active.jsonl",
154
+ messages: [],
155
+ isStreaming: false,
156
+ sessionManager: { setSessionFile(nextPath: string) { openedPath = nextPath; }, getEntries: () => [], isPersisted: () => true, getSessionDir: () => directory, buildSessionContext: () => ({ messages: [] }) },
157
+ agent: { state: { messages: [] } },
158
+ subscribe: () => () => {},
159
+ };
160
+ context.provide("piRuntime", { session, prompt: () => Promise.resolve() } as never);
161
+ context.provide("piModels", { model: { provider: "test", id: "model" } } as never);
162
+ context.provide("piHarnessLaunch", { cwd: "/tmp", agentDir: "/tmp/agent", args: [], requestExit() {} });
163
+ await context.plugin(apiPlugin);
164
+
165
+ const response = await fetch(context.webServer.url + "/api/session/open", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path }) });
166
+ expect(response.status).toBe(200);
167
+ expect(openedPath).toBe(path);
168
+ });
169
+
170
+ test("lists and selects models through the live Pi session", async () => {
171
+ const context = new Context();
172
+ contexts.push(context);
173
+ await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
174
+ const first = { provider: "test", id: "one", name: "Test One", reasoning: false, contextWindow: 8_000 };
175
+ const second = { provider: "test", id: "two", name: "Test Two", reasoning: true, contextWindow: 16_000 };
176
+ let selected = "one";
177
+ const session = {
178
+ sessionId: "model-session",
179
+ sessionFile: undefined,
180
+ messages: [],
181
+ isStreaming: false,
182
+ get model() { return selected === "one" ? first : second; },
183
+ setModel(model: { id: string }) { selected = model.id; return Promise.resolve(); },
184
+ subscribe: () => () => {},
185
+ };
186
+ const modelRuntime = { getModels: () => [first, second], getModel: (_provider: string, id: string) => id === "two" ? second : id === "one" ? first : undefined };
187
+ context.provide("piRuntime", { session, prompt: () => Promise.resolve(), abort: () => Promise.resolve(), dispose: () => Promise.resolve() } as never);
188
+ context.provide("piModels", { model: first, runtime: modelRuntime } as never);
189
+ context.provide("piHarnessLaunch", { cwd: "/tmp", agentDir: "/tmp/agent", args: [], requestExit() {} });
190
+ await context.plugin(apiPlugin);
191
+
192
+ await expect(fetch(context.webServer.url + "/api/models")).resolves.toMatchObject({ status: 200 });
193
+ const list = await fetch(context.webServer.url + "/api/models");
194
+ await expect(list.json()).resolves.toMatchObject({ items: [{ id: "one", active: true }, { id: "two", active: false }] });
195
+ const providers = await fetch(context.webServer.url + "/api/providers");
196
+ await expect(providers.json()).resolves.toMatchObject({ items: [{ provider: "test", activeModel: { id: "one", active: true } }] });
197
+ const response = await fetch(context.webServer.url + "/api/model", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider: "test", model: "two" }) });
198
+ expect(response.status).toBe(200);
199
+ await expect(response.json()).resolves.toMatchObject({ model: { id: "two", active: true } });
200
+ expect(selected).toBe("two");
201
+ });
202
+
203
+ test("only exposes the active and configured providers", async () => {
204
+ const context = new Context();
205
+ contexts.push(context);
206
+ await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
207
+ const active = { provider: "active", id: "one", name: "Active", reasoning: false, contextWindow: 8_000 };
208
+ const configured = { provider: "configured", id: "one", name: "Configured", reasoning: false, contextWindow: 8_000 };
209
+ const hidden = { provider: "hidden", id: "one", name: "Hidden", reasoning: false, contextWindow: 8_000 };
210
+ const session = { sessionId: "provider-filter-session", sessionFile: undefined, messages: [], isStreaming: false, model: active, subscribe: () => () => {} };
211
+ const modelRuntime = { getProviders: () => [{ id: "active", name: "Active" }, { id: "configured", name: "Configured" }, { id: "hidden", name: "Hidden" }], getModels: (provider?: string) => provider === "configured" ? [configured] : provider === "hidden" ? [hidden] : [active], getProviderAuthStatus: (provider: string) => provider === "configured" ? { configured: true, source: "environment" } : { configured: false }, getModel: () => active };
212
+ context.provide("piRuntime", { session, prompt: () => Promise.resolve() } as never);
213
+ context.provide("piModels", { model: active, runtime: modelRuntime } as never);
214
+ context.provide("piHarnessLaunch", { cwd: "/tmp", agentDir: "/tmp/agent", args: [], requestExit() {} });
215
+ await context.plugin(apiPlugin);
216
+
217
+ const response = await fetch(context.webServer.url + "/api/providers");
218
+ expect(response.status).toBe(200);
219
+ await expect(response.json()).resolves.toMatchObject({ items: [{ provider: "active" }, { provider: "configured" }] });
220
+ });
221
+
222
+ test("lists commands from the live extension registry", async () => {
223
+ const context = new Context();
224
+ contexts.push(context);
225
+ await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
226
+ const session = {
227
+ sessionId: "command-session",
228
+ sessionFile: undefined,
229
+ messages: [],
230
+ isStreaming: false,
231
+ extensionRunner: { getRegisteredCommands: () => [{ name: "review", invocationName: "review", description: "Review changes", sourceInfo: { path: "/tmp/review.ts" } }] },
232
+ subscribe: () => () => {},
233
+ };
234
+ context.provide("piRuntime", { session, prompt: () => Promise.resolve() } as never);
235
+ context.provide("piModels", { model: { provider: "test", id: "model" } } as never);
236
+ context.provide("piHarnessLaunch", { cwd: "/tmp", agentDir: "/tmp/agent", args: [], requestExit() {} });
237
+ await context.plugin(apiPlugin);
238
+
239
+ const response = await fetch(context.webServer.url + "/api/commands");
240
+ expect(response.status).toBe(200);
241
+ await expect(response.json()).resolves.toEqual({ items: [{ name: "review", invocationName: "review", description: "Review changes", source: "/tmp/review.ts" }] });
242
+ });
243
+
244
+ test("aborts a running prompt through the web API", async () => {
245
+ const context = new Context();
246
+ contexts.push(context);
247
+ await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
248
+ let aborted = false;
249
+ const session = { sessionId: "abort-session", sessionFile: undefined, messages: [], isStreaming: true, subscribe: () => () => {} };
250
+ context.provide("piRuntime", { session, prompt: () => Promise.resolve(), abort: () => { aborted = true; session.isStreaming = false; return Promise.resolve(); }, dispose: () => Promise.resolve() } as never);
251
+ context.provide("piModels", { model: { provider: "test", id: "model" }, runtime: { getModels: () => [], getModel: () => undefined } } as never);
252
+ context.provide("piHarnessLaunch", { cwd: "/tmp", agentDir: "/tmp/agent", args: [], requestExit() {} });
253
+ await context.plugin(apiPlugin);
254
+
255
+ const response = await fetch(context.webServer.url + "/api/abort", { method: "POST" });
256
+ expect(response.status).toBe(200);
257
+ await expect(response.json()).resolves.toEqual({ aborted: true });
258
+ expect(aborted).toBe(true);
259
+ });
260
+
261
+ test("reports workspace file status without exposing a fake action", async () => {
262
+ const context = new Context();
263
+ contexts.push(context);
264
+ await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
265
+ const session = { sessionId: "files-session", sessionFile: undefined, messages: [], isStreaming: false, subscribe: () => () => {} };
266
+ context.provide("piRuntime", { session, prompt: () => Promise.resolve(), abort: () => Promise.resolve(), dispose: () => Promise.resolve() } as never);
267
+ context.provide("piModels", { model: { provider: "test", id: "model" }, runtime: { getModels: () => [], getModel: () => undefined } } as never);
268
+ context.provide("piHarnessLaunch", { cwd: "/tmp", agentDir: "/tmp/agent", args: [], requestExit() {} });
269
+ await context.plugin(apiPlugin);
270
+
271
+ const response = await fetch(context.webServer.url + "/api/files");
272
+ expect(response.status).toBe(200);
273
+ const payload = await response.json() as { items?: unknown };
274
+ expect(Array.isArray(payload.items)).toBe(true);
275
+
276
+ const diff = await fetch(context.webServer.url + "/api/files/diff?path=README.md");
277
+ expect(diff.status).toBe(200);
278
+ const diffPayload = await diff.json() as { path?: unknown; diff?: unknown };
279
+ expect(diffPayload.path).toBe("README.md");
280
+ expect(typeof diffPayload.diff).toBe("string");
281
+ const invalid = await fetch(context.webServer.url + "/api/files/diff?path=../secrets.txt");
282
+ expect(invalid.status).toBe(400);
283
+ });
284
+
285
+ test("commits selected workspace files only after an explicit message", async () => {
286
+ const context = new Context();
287
+ contexts.push(context);
288
+ const directory = await mkdtemp(join(tmpdir(), "pi-harness-api-commit-"));
289
+ await execFile("git", ["init", "-q"], { cwd: directory });
290
+ await execFile("git", ["config", "user.email", "pi-harness@test.invalid"], { cwd: directory });
291
+ await execFile("git", ["config", "user.name", "Pi Harness Test"], { cwd: directory });
292
+ await writeFile(join(directory, "README.md"), "before\n", "utf8");
293
+ await execFile("git", ["add", "README.md"], { cwd: directory });
294
+ await execFile("git", ["commit", "-qm", "initial"], { cwd: directory });
295
+ await writeFile(join(directory, "README.md"), "after\n", "utf8");
296
+ await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
297
+ const session = { sessionId: "commit-session", sessionFile: undefined, messages: [], isStreaming: false, subscribe: () => () => {} };
298
+ context.provide("piRuntime", { session, prompt: () => Promise.resolve(), abort: () => Promise.resolve(), dispose: () => Promise.resolve() } as never);
299
+ context.provide("piModels", { model: { provider: "test", id: "model" }, runtime: { getModels: () => [], getModel: () => undefined } } as never);
300
+ context.provide("piHarnessLaunch", { cwd: directory, agentDir: "/tmp/agent", args: [], requestExit() {} });
301
+ await context.plugin(apiPlugin);
302
+
303
+ const missingMessage = await fetch(context.webServer.url + "/api/files/commit", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ paths: ["README.md"] }) });
304
+ expect(missingMessage.status).toBe(400);
305
+ const response = await fetch(context.webServer.url + "/api/files/commit", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ paths: ["README.md"], message: "Update README" }) });
306
+ expect(response.status).toBe(200);
307
+ await expect(response.json()).resolves.toMatchObject({ committed: true, message: "Update README" });
308
+ await expect(execFile("git", ["status", "--porcelain"], { cwd: directory })).resolves.toMatchObject({ stdout: "" });
309
+ });
310
+
311
+ test("rejects destructive workspace revert without explicit confirmation", async () => {
312
+ const context = new Context();
313
+ contexts.push(context);
314
+ const directory = await mkdtemp(join(tmpdir(), "pi-harness-api-revert-"));
315
+ await execFile("git", ["init", "-q"], { cwd: directory });
316
+ await writeFile(join(directory, "scratch.txt"), "discard me\n", "utf8");
317
+ await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
318
+ const session = { sessionId: "revert-session", sessionFile: undefined, messages: [], isStreaming: false, subscribe: () => () => {} };
319
+ context.provide("piRuntime", { session, prompt: () => Promise.resolve(), abort: () => Promise.resolve(), dispose: () => Promise.resolve() } as never);
320
+ context.provide("piModels", { model: { provider: "test", id: "model" }, runtime: { getModels: () => [], getModel: () => undefined } } as never);
321
+ context.provide("piHarnessLaunch", { cwd: directory, agentDir: "/tmp/agent", args: [], requestExit() {} });
322
+ await context.plugin(apiPlugin);
323
+
324
+ const response = await fetch(context.webServer.url + "/api/files/revert", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ paths: ["scratch.txt"] }) });
325
+ expect(response.status).toBe(400);
326
+ });
327
+ });
@@ -0,0 +1 @@
1
+ {"extends":"../../tsconfig.build.json","compilerOptions":{"outDir":"dist","paths":{},"rootDir":"src"},"include":["src/**/*.ts"]}
@@ -0,0 +1 @@
1
+ {"extends":"../../tsconfig.json","compilerOptions":{"paths":{}},"include":["src/**/*.ts"]}
@@ -0,0 +1,12 @@
1
+ - id: webserver
2
+ name: "@pi-harness/host-webserver"
3
+ config:
4
+ host: 127.0.0.1
5
+ port: 3080
6
+ - id: api
7
+ name: "@pi-harness/api-gateway"
8
+ config: {}
9
+ - id: web-app
10
+ name: "@pi-harness/web-app"
11
+ config:
12
+ staticDir: !!js process.env.PI_HARNESS_WEB_DIST
@@ -0,0 +1,12 @@
1
+ import type { Context } from "@deepseek-ai/cordis";
2
+ import "@pi-harness/host-webserver";
3
+ export interface WebAppConfig {
4
+ readonly staticDir?: string;
5
+ }
6
+ declare const _default: {
7
+ name: string;
8
+ inject: string[];
9
+ apply(context: Context, config: WebAppConfig): void;
10
+ };
11
+ export default _default;
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAEnD,OAAO,4BAA4B,CAAC;AAEpC,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;;;;mBAuCgB,OAAO,UAAU,YAAY;;AAH9C,wBAsBE"}
@@ -0,0 +1,63 @@
1
+ import { createReadStream, existsSync } from "node:fs";
2
+ import { realpath, stat } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { pipeline } from "node:stream/promises";
5
+ import "@pi-harness/host-webserver";
6
+ const MIME_TYPES = {
7
+ ".css": "text/css; charset=utf-8",
8
+ ".html": "text/html; charset=utf-8",
9
+ ".js": "text/javascript; charset=utf-8",
10
+ ".json": "application/json; charset=utf-8",
11
+ ".svg": "image/svg+xml",
12
+ };
13
+ function contentType(path) {
14
+ const extension = path.slice(path.lastIndexOf(".")).toLowerCase();
15
+ return MIME_TYPES[extension] ?? "application/octet-stream";
16
+ }
17
+ function safePath(root, pathname) {
18
+ const candidate = resolve(root, "." + pathname);
19
+ const rootWithSlash = root.endsWith("/") ? root : root + "/";
20
+ if (candidate !== root && !candidate.startsWith(rootWithSlash))
21
+ return undefined;
22
+ return candidate;
23
+ }
24
+ async function sendFile(path, response) {
25
+ const info = await stat(path);
26
+ if (!info.isFile())
27
+ throw new Error("Not a file");
28
+ response.writeHead(200, { "content-type": contentType(path), "content-length": info.size, "cache-control": "no-cache" });
29
+ await pipeline(createReadStream(path), response);
30
+ }
31
+ async function sendSafeFile(root, path, response) {
32
+ const [rootReal, pathReal] = await Promise.all([realpath(root), realpath(path)]);
33
+ const rootWithSlash = rootReal.endsWith("/") ? rootReal : rootReal + "/";
34
+ if (pathReal !== rootReal && !pathReal.startsWith(rootWithSlash))
35
+ throw new Error("Static file escapes web root");
36
+ await sendFile(pathReal, response);
37
+ }
38
+ export default {
39
+ name: "pi-web-app",
40
+ inject: ["webServer"],
41
+ apply(context, config) {
42
+ const root = resolve(config.staticDir ?? "dist");
43
+ if (!existsSync(root))
44
+ throw new Error("Web frontend dist does not exist: " + root);
45
+ const dispose = context.webServer.registerFallback(async (request, response) => {
46
+ const requestPath = new URL(request.url ?? "/", context.webServer.url).pathname;
47
+ const requested = safePath(root, requestPath);
48
+ const index = join(root, "index.html");
49
+ if (requested !== undefined) {
50
+ try {
51
+ await sendSafeFile(root, requested, response);
52
+ return;
53
+ }
54
+ catch {
55
+ // SPA fallback below.
56
+ }
57
+ }
58
+ await sendSafeFile(root, index, response);
59
+ });
60
+ context.effect(() => dispose);
61
+ },
62
+ };
63
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@pi-harness/web-app",
3
+ "version": "0.1.1",
4
+ "description": "Patchable Web application bundle for Pi Harness",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./cordis.patch.yml": "./cordis.patch.yml"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "cordis.patch.yml"
18
+ ],
19
+ "engines": {
20
+ "node": ">=22.19.0"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.build.json",
24
+ "typecheck": "tsc -p tsconfig.json"
25
+ },
26
+ "dependencies": {
27
+ "@deepseek-ai/cordis": "4.0.1",
28
+ "@pi-harness/host-webserver": "0.1.1"
29
+ }
30
+ }
@@ -0,0 +1,69 @@
1
+ import { createReadStream, existsSync } from "node:fs";
2
+ import { realpath, stat } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { pipeline } from "node:stream/promises";
5
+ import type { Context } from "@deepseek-ai/cordis";
6
+ import type { IncomingMessage, ServerResponse } from "node:http";
7
+ import "@pi-harness/host-webserver";
8
+
9
+ export interface WebAppConfig {
10
+ readonly staticDir?: string;
11
+ }
12
+
13
+ const MIME_TYPES: Record<string, string> = {
14
+ ".css": "text/css; charset=utf-8",
15
+ ".html": "text/html; charset=utf-8",
16
+ ".js": "text/javascript; charset=utf-8",
17
+ ".json": "application/json; charset=utf-8",
18
+ ".svg": "image/svg+xml",
19
+ };
20
+
21
+ function contentType(path: string): string {
22
+ const extension = path.slice(path.lastIndexOf(".")).toLowerCase();
23
+ return MIME_TYPES[extension] ?? "application/octet-stream";
24
+ }
25
+
26
+ function safePath(root: string, pathname: string): string | undefined {
27
+ const candidate = resolve(root, "." + pathname);
28
+ const rootWithSlash = root.endsWith("/") ? root : root + "/";
29
+ if (candidate !== root && !candidate.startsWith(rootWithSlash)) return undefined;
30
+ return candidate;
31
+ }
32
+
33
+ async function sendFile(path: string, response: ServerResponse): Promise<void> {
34
+ const info = await stat(path);
35
+ if (!info.isFile()) throw new Error("Not a file");
36
+ response.writeHead(200, { "content-type": contentType(path), "content-length": info.size, "cache-control": "no-cache" });
37
+ await pipeline(createReadStream(path), response);
38
+ }
39
+
40
+ async function sendSafeFile(root: string, path: string, response: ServerResponse): Promise<void> {
41
+ const [rootReal, pathReal] = await Promise.all([realpath(root), realpath(path)]);
42
+ const rootWithSlash = rootReal.endsWith("/") ? rootReal : rootReal + "/";
43
+ if (pathReal !== rootReal && !pathReal.startsWith(rootWithSlash)) throw new Error("Static file escapes web root");
44
+ await sendFile(pathReal, response);
45
+ }
46
+
47
+ export default {
48
+ name: "pi-web-app",
49
+ inject: ["webServer"],
50
+ apply(context: Context, config: WebAppConfig) {
51
+ const root = resolve(config.staticDir ?? "dist");
52
+ if (!existsSync(root)) throw new Error("Web frontend dist does not exist: " + root);
53
+ const dispose = context.webServer.registerFallback(async (request: IncomingMessage, response: ServerResponse) => {
54
+ const requestPath = new URL(request.url ?? "/", context.webServer.url).pathname;
55
+ const requested = safePath(root, requestPath);
56
+ const index = join(root, "index.html");
57
+ if (requested !== undefined) {
58
+ try {
59
+ await sendSafeFile(root, requested, response);
60
+ return;
61
+ } catch {
62
+ // SPA fallback below.
63
+ }
64
+ }
65
+ await sendSafeFile(root, index, response);
66
+ });
67
+ context.effect(() => dispose);
68
+ },
69
+ };
@@ -0,0 +1,29 @@
1
+ import { mkdtemp, symlink, 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 { afterEach, describe, expect, test } from "vitest";
6
+ import webServerPlugin from "@pi-harness/host-webserver";
7
+ import webAppPlugin from "../src/index.js";
8
+
9
+ const contexts: Context[] = [];
10
+
11
+ afterEach(async () => {
12
+ await Promise.all(contexts.splice(0).map(async (context) => context.fiber.dispose()));
13
+ });
14
+
15
+ describe("web app plugin", () => {
16
+ test("serves the SPA and refuses symlink escapes", async () => {
17
+ const root = await mkdtemp(join(tmpdir(), "pi-harness-web-root-"));
18
+ await writeFile(join(root, "index.html"), "<main>console</main>", "utf8");
19
+ await symlink("/etc/hosts", join(root, "leak"));
20
+ const context = new Context();
21
+ contexts.push(context);
22
+ await context.plugin(webServerPlugin, { host: "127.0.0.1", port: 0 });
23
+ await context.plugin(webAppPlugin, { staticDir: root });
24
+
25
+ await expect(fetch(context.webServer.url + "/").then((response) => response.text())).resolves.toBe("<main>console</main>");
26
+ await expect(fetch(context.webServer.url + "/missing-route").then((response) => response.text())).resolves.toBe("<main>console</main>");
27
+ await expect(fetch(context.webServer.url + "/leak").then((response) => response.text())).resolves.toBe("<main>console</main>");
28
+ });
29
+ });
@@ -0,0 +1 @@
1
+ {"extends":"../../tsconfig.build.json","compilerOptions":{"outDir":"dist","paths":{},"rootDir":"src"},"include":["src/**/*.ts"]}
@@ -0,0 +1 @@
1
+ {"extends":"../../tsconfig.json","compilerOptions":{"paths":{}},"include":["src/**/*.ts"]}
@@ -0,0 +1,16 @@
1
+ export declare class CliUsageError extends Error {
2
+ readonly name = "CliUsageError";
3
+ }
4
+ export type LauncherInvocation = {
5
+ mode: "help";
6
+ } | {
7
+ mode: "version";
8
+ } | {
9
+ mode: "run";
10
+ profile?: string;
11
+ configPath?: string;
12
+ dumpConfig: boolean;
13
+ args: string[];
14
+ };
15
+ export declare function parseLauncherArgs(args: readonly string[]): LauncherInvocation;
16
+ //# sourceMappingURL=args.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"args.d.ts","sourceRoot":"","sources":["../src/args.ts"],"names":[],"mappings":"AAAA,qBAAa,aAAc,SAAQ,KAAK;IACtC,SAAkB,IAAI,mBAAmB;CAC1C;AAED,MAAM,MAAM,kBAAkB,GAAG;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC;AAQtK,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,kBAAkB,CAqC7E"}
@@ -0,0 +1,53 @@
1
+ export class CliUsageError extends Error {
2
+ name = "CliUsageError";
3
+ }
4
+ function optionValue(args, index, option) {
5
+ const value = args[index + 1];
6
+ if (value === undefined || value === "--")
7
+ throw new CliUsageError(`${option} requires a value`);
8
+ return value;
9
+ }
10
+ export function parseLauncherArgs(args) {
11
+ let profile = "default";
12
+ let profileExplicit = false;
13
+ let configPath;
14
+ let dumpConfig = false;
15
+ const applicationArgs = [];
16
+ for (let index = 0; index < args.length; index += 1) {
17
+ const arg = args[index];
18
+ if (arg === "--") {
19
+ applicationArgs.push(...args.slice(index + 1));
20
+ break;
21
+ }
22
+ if (arg === "--help" || arg === "-h")
23
+ return { mode: "help" };
24
+ if (arg === "--version" || arg === "-v")
25
+ return { mode: "version" };
26
+ if (arg === "--dump-config") {
27
+ dumpConfig = true;
28
+ continue;
29
+ }
30
+ if (arg === "--profile") {
31
+ if (profileExplicit)
32
+ throw new CliUsageError("--profile may only be specified once");
33
+ profile = optionValue(args, index, "--profile");
34
+ profileExplicit = true;
35
+ index += 1;
36
+ continue;
37
+ }
38
+ if (arg === "--config") {
39
+ if (configPath !== undefined)
40
+ throw new CliUsageError("--config may only be specified once");
41
+ configPath = optionValue(args, index, "--config");
42
+ index += 1;
43
+ continue;
44
+ }
45
+ applicationArgs.push(arg ?? "");
46
+ }
47
+ if (profileExplicit && configPath !== undefined)
48
+ throw new CliUsageError("--profile and --config cannot be used together");
49
+ return configPath === undefined
50
+ ? { mode: "run", profile, dumpConfig, args: applicationArgs }
51
+ : { mode: "run", configPath, dumpConfig, args: applicationArgs };
52
+ }
53
+ //# sourceMappingURL=args.js.map