@tt-a1i/openpi 0.5.0 → 0.6.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.
- package/README.md +30 -20
- package/SETUP.md +10 -4
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/bin/openpi.js +25 -15
- package/extensions/ai-providers/LICENSE.upstream +23 -0
- package/extensions/ai-providers/README.md +65 -0
- package/extensions/ai-providers/antigravity/credentials.ts +52 -0
- package/extensions/ai-providers/antigravity/discovery.ts +130 -0
- package/extensions/ai-providers/antigravity/google-conversion.ts +455 -0
- package/extensions/ai-providers/antigravity/models.ts +84 -0
- package/extensions/ai-providers/antigravity/oauth.ts +700 -0
- package/extensions/ai-providers/antigravity/provider.ts +1116 -0
- package/extensions/ai-providers/antigravity/routing.ts +340 -0
- package/extensions/ai-providers/antigravity/with-resolvers.d.ts +19 -0
- package/extensions/ai-providers/cursor/constants.ts +5 -0
- package/extensions/ai-providers/cursor/credentials.ts +14 -0
- package/extensions/ai-providers/cursor/discovery.ts +291 -0
- package/extensions/ai-providers/cursor/input-images.ts +105 -0
- package/extensions/ai-providers/cursor/models.ts +45 -0
- package/extensions/ai-providers/cursor/oauth.ts +263 -0
- package/extensions/ai-providers/cursor/proto.ts +1271 -0
- package/extensions/ai-providers/cursor/protobuf.ts +1181 -0
- package/extensions/ai-providers/cursor/provider.ts +1431 -0
- package/extensions/ai-providers/cursor/proxy.ts +213 -0
- package/extensions/ai-providers/cursor/tool-bridge.ts +68 -0
- package/extensions/ai-providers/cursor/with-resolvers.d.ts +12 -0
- package/extensions/ai-providers/index.ts +86 -0
- package/extensions/ai-providers/oauth-adapter.ts +81 -0
- package/extensions/ai-providers/usage.ts +10 -0
- package/extensions/background-terminals/index.ts +8 -1
- package/extensions/background-terminals/src/manager.ts +3 -5
- package/extensions/background-terminals/src/result-delivery.ts +43 -23
- package/extensions/cron/index.ts +68 -27
- package/extensions/cron/schedule.ts +5 -1
- package/extensions/model-info/cache-diagnostics.ts +220 -0
- package/extensions/model-info/index.ts +45 -1
- package/extensions/plan-mode/index.ts +75 -4
- package/extensions/setup/index.ts +15 -3
- package/extensions/shared/child-session.ts +39 -5
- package/extensions/shared/completion-inbox.ts +193 -0
- package/extensions/shared/setup-config.ts +10 -1
- package/extensions/shared/structured-output.ts +154 -0
- package/extensions/subagents/index.ts +64 -7
- package/extensions/subagents/src/agent-types.ts +5 -17
- package/extensions/subagents/src/backends/pi.ts +130 -48
- package/extensions/subagents/src/backends/tool-preview.ts +29 -0
- package/extensions/subagents/src/domain.ts +16 -1
- package/extensions/subagents/src/manager.ts +7 -71
- package/extensions/subagents/src/prompt.ts +19 -5
- package/extensions/subagents/src/result-artifact.ts +32 -0
- package/extensions/subagents/src/result-delivery.ts +33 -14
- package/extensions/subagents/src/runtime.ts +10 -3
- package/extensions/ui-customization/footer.ts +16 -5
- package/extensions/user-input-fold/index.ts +42 -6
- package/extensions/web/index.ts +25 -2
- package/extensions/workflows/acceptance.ts +43 -19
- package/extensions/workflows/completion-projection.ts +3 -1
- package/extensions/workflows/dashboard.ts +147 -21
- package/extensions/workflows/index.ts +75 -20
- package/extensions/workflows/model.ts +5 -1
- package/extensions/workflows/progress-projection.ts +7 -1
- package/extensions/workflows/prompt.ts +4 -10
- package/extensions/workflows/result-delivery.ts +96 -22
- package/extensions/workflows/retention.ts +6 -0
- package/extensions/workflows/runner.ts +11 -233
- package/extensions/workflows/sandbox.ts +4 -0
- package/package.json +7 -7
- package/skills/subagents/REFERENCE.md +9 -9
- package/skills/subagents/SKILL.md +2 -1
- package/skills/workflows/REFERENCE.md +5 -3
- package/skills/workflows/SKILL.md +1 -1
- package/web/adapter/pi-adapter.ts +3 -0
- package/web/host/pi-coding-agent-entry.ts +162 -0
- package/web/host/web-host.ts +330 -50
- package/web/protocol/types.ts +5 -0
- package/web/runtime/pi-runtime.ts +240 -25
- package/web/runtime/types.ts +32 -1
- package/web/ui/app.js +343 -41
- package/web/ui/index.html +3 -0
- package/web/ui/styles.css +119 -37
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
export const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
6
|
+
export const PI_CODING_AGENT_ENTRY_ENV = "OPENPI_PI_CODING_AGENT_ENTRY";
|
|
7
|
+
const PACKAGE_ROOT_SEARCH_DEPTH = 10;
|
|
8
|
+
|
|
9
|
+
type PackageManifest = {
|
|
10
|
+
name?: unknown;
|
|
11
|
+
main?: unknown;
|
|
12
|
+
exports?: Record<string, { import?: unknown } | string>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function findPackageRoot(realPath: string, packageName: string) {
|
|
16
|
+
let dir = dirname(realPath);
|
|
17
|
+
for (let depth = 0; depth < PACKAGE_ROOT_SEARCH_DEPTH; depth++) {
|
|
18
|
+
const manifestPath = join(dir, "package.json");
|
|
19
|
+
if (existsSync(manifestPath)) {
|
|
20
|
+
const manifest = readManifest(manifestPath);
|
|
21
|
+
if (manifest?.name === packageName) return dir;
|
|
22
|
+
}
|
|
23
|
+
const parent = dirname(dir);
|
|
24
|
+
if (parent === dir) break;
|
|
25
|
+
dir = parent;
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readManifest(manifestPath: string) {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(readFileSync(manifestPath, "utf8")) as PackageManifest;
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function officialEntry(root: string | undefined) {
|
|
39
|
+
if (!root) return undefined;
|
|
40
|
+
const manifest = readManifest(join(root, "package.json"));
|
|
41
|
+
const target = manifest?.exports?.["."];
|
|
42
|
+
const relative =
|
|
43
|
+
typeof target === "string"
|
|
44
|
+
? target
|
|
45
|
+
: typeof target?.import === "string"
|
|
46
|
+
? target.import
|
|
47
|
+
: typeof manifest?.main === "string"
|
|
48
|
+
? manifest.main
|
|
49
|
+
: "dist/index.js";
|
|
50
|
+
const entry = join(root, relative);
|
|
51
|
+
try {
|
|
52
|
+
return existsSync(entry) && statSync(entry).isFile()
|
|
53
|
+
? realpathSync(entry)
|
|
54
|
+
: undefined;
|
|
55
|
+
} catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function walkFromFile(file: string) {
|
|
61
|
+
try {
|
|
62
|
+
const real = realpathSync(file);
|
|
63
|
+
if (!statSync(real).isFile()) return undefined;
|
|
64
|
+
return officialEntry(findPackageRoot(real, PI_CODING_AGENT_PACKAGE));
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function fileFromUrl(fromUrl: string) {
|
|
71
|
+
return fromUrl.startsWith("file:") ? fileURLToPath(fromUrl) : fromUrl;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function nearestPackageRoot(file: string) {
|
|
75
|
+
let dir = dirname(file);
|
|
76
|
+
for (let depth = 0; depth < PACKAGE_ROOT_SEARCH_DEPTH; depth++) {
|
|
77
|
+
if (existsSync(join(dir, "package.json"))) return dir;
|
|
78
|
+
const parent = dirname(dir);
|
|
79
|
+
if (parent === dir) break;
|
|
80
|
+
dir = parent;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function peerAt(nodeModules: string) {
|
|
85
|
+
const root = join(nodeModules, ...PI_CODING_AGENT_PACKAGE.split("/"));
|
|
86
|
+
const manifest = readManifest(join(root, "package.json"));
|
|
87
|
+
return manifest?.name === PI_CODING_AGENT_PACKAGE
|
|
88
|
+
? officialEntry(root)
|
|
89
|
+
: undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resolveFromInstall(fromUrl: string) {
|
|
93
|
+
let start = fileFromUrl(fromUrl);
|
|
94
|
+
try {
|
|
95
|
+
start = realpathSync(start);
|
|
96
|
+
} catch {
|
|
97
|
+
// Keep the unresolved path when the caller file is a test stub.
|
|
98
|
+
}
|
|
99
|
+
const packageRoot = nearestPackageRoot(start);
|
|
100
|
+
if (!packageRoot) return undefined;
|
|
101
|
+
|
|
102
|
+
const nested = peerAt(join(packageRoot, "node_modules"));
|
|
103
|
+
if (nested) return nested;
|
|
104
|
+
|
|
105
|
+
const parent = dirname(packageRoot);
|
|
106
|
+
const grandparent = dirname(parent);
|
|
107
|
+
const hoistedModules =
|
|
108
|
+
basename(parent).startsWith("@") && basename(grandparent) === "node_modules"
|
|
109
|
+
? grandparent
|
|
110
|
+
: basename(parent) === "node_modules"
|
|
111
|
+
? parent
|
|
112
|
+
: undefined;
|
|
113
|
+
return hoistedModules ? peerAt(hoistedModules) : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function validatePiCodingAgentEntry(candidate: string | undefined) {
|
|
117
|
+
if (!candidate) return undefined;
|
|
118
|
+
return walkFromFile(candidate);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function missingPiCodingAgentDiagnostic() {
|
|
122
|
+
return [
|
|
123
|
+
`OpenPI Web could not resolve ${PI_CODING_AGENT_PACKAGE} for this process.`,
|
|
124
|
+
"Host resolution uses only the current process argv identity and fail-closes if that path is not the official package.",
|
|
125
|
+
`${PI_CODING_AGENT_ENTRY_ENV} is an explicit standalone handoff, not a host fallback.`,
|
|
126
|
+
`Standalone openpi web uses that handoff when valid, then the installed nested or hoisted peer (npm install ${PI_CODING_AGENT_PACKAGE}).`,
|
|
127
|
+
"From a running Pi session use /web, which hands over the host Pi.",
|
|
128
|
+
"Supported package install is `pi install npm:@tt-a1i/openpi`.",
|
|
129
|
+
].join(" ");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function resolvePiCodingAgentEntry(options?: {
|
|
133
|
+
source?: "host" | "standalone";
|
|
134
|
+
env?: NodeJS.ProcessEnv;
|
|
135
|
+
argv1?: string | undefined;
|
|
136
|
+
fromUrl?: string;
|
|
137
|
+
}) {
|
|
138
|
+
const source = options?.source ?? "host";
|
|
139
|
+
if (source === "standalone") {
|
|
140
|
+
const env = options?.env ?? process.env;
|
|
141
|
+
const handed = validatePiCodingAgentEntry(env[PI_CODING_AGENT_ENTRY_ENV]);
|
|
142
|
+
if (handed) return handed;
|
|
143
|
+
return resolveFromInstall(options?.fromUrl ?? import.meta.url);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const argv1 = options?.argv1 === undefined ? process.argv[1] : options.argv1;
|
|
147
|
+
return argv1 ? walkFromFile(argv1) : undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function resolveStandaloneJitiAliases(options?: {
|
|
151
|
+
env?: NodeJS.ProcessEnv;
|
|
152
|
+
argv1?: string | undefined;
|
|
153
|
+
fromUrl?: string;
|
|
154
|
+
}) {
|
|
155
|
+
const fromUrl = options?.fromUrl ?? import.meta.url;
|
|
156
|
+
const entry = resolvePiCodingAgentEntry({
|
|
157
|
+
...options,
|
|
158
|
+
fromUrl,
|
|
159
|
+
source: "standalone",
|
|
160
|
+
});
|
|
161
|
+
return entry ? { [PI_CODING_AGENT_PACKAGE]: entry } : {};
|
|
162
|
+
}
|
package/web/host/web-host.ts
CHANGED
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
} from "node:http";
|
|
10
10
|
import { URL } from "node:url";
|
|
11
11
|
import { promisify } from "node:util";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
subscribeWebCapabilities,
|
|
14
|
+
webCapabilitySnapshot,
|
|
15
|
+
} from "../../extensions/shared/web-observer-registry.ts";
|
|
16
|
+
import { loadSetupConfig } from "../../extensions/shared/setup-config.ts";
|
|
13
17
|
import { PiWebAdapter } from "../adapter/pi-adapter.ts";
|
|
14
18
|
import {
|
|
15
19
|
jsonByteLength,
|
|
@@ -33,10 +37,47 @@ const MAX_COMMAND_BYTES = 16 * 1024;
|
|
|
33
37
|
const MAX_SSE_CLIENTS = 8;
|
|
34
38
|
const MAX_SSE_BUFFER_BYTES = 256 * 1024;
|
|
35
39
|
const MAX_SSE_REPLAY_BYTES = MAX_SSE_BUFFER_BYTES;
|
|
40
|
+
const DEFAULT_SSE_HEARTBEAT_MS = 15_000;
|
|
36
41
|
const SERVER_CLOSE_DRAIN_MS = 500;
|
|
37
42
|
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
43
|
+
const MAX_PROMPT_ADMISSIONS = 128;
|
|
38
44
|
const execFileAsync = promisify(execFile);
|
|
39
45
|
|
|
46
|
+
type PromptAdmissionResponse = {
|
|
47
|
+
readonly status: number;
|
|
48
|
+
readonly body: Record<string, unknown>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type PromptAdmission = {
|
|
52
|
+
readonly sessionId: string;
|
|
53
|
+
readonly content: string;
|
|
54
|
+
readonly completion: Promise<PromptAdmissionResponse>;
|
|
55
|
+
result?: PromptAdmissionResponse;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type WebRequestErrorCode =
|
|
59
|
+
| "INVALID_REQUEST_BODY"
|
|
60
|
+
| "REQUEST_BODY_TOO_LARGE";
|
|
61
|
+
|
|
62
|
+
class WebRequestError extends Error {
|
|
63
|
+
readonly code: WebRequestErrorCode;
|
|
64
|
+
readonly statusCode: 400 | 413;
|
|
65
|
+
readonly maxBytes?: number;
|
|
66
|
+
|
|
67
|
+
constructor(
|
|
68
|
+
message: string,
|
|
69
|
+
code: WebRequestErrorCode,
|
|
70
|
+
statusCode: 400 | 413,
|
|
71
|
+
maxBytes?: number,
|
|
72
|
+
) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = "WebRequestError";
|
|
75
|
+
this.code = code;
|
|
76
|
+
this.statusCode = statusCode;
|
|
77
|
+
this.maxBytes = maxBytes;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
40
81
|
export interface WebHostOptions {
|
|
41
82
|
runtime: WebRuntimeController;
|
|
42
83
|
onEvent?: (type: string, detail?: Record<string, unknown>) => void;
|
|
@@ -45,6 +86,7 @@ export interface WebHostOptions {
|
|
|
45
86
|
allowedOrigins?: readonly string[];
|
|
46
87
|
directoryChooser?: (signal: AbortSignal) => Promise<string | undefined>;
|
|
47
88
|
shutdownTimeoutMs?: number;
|
|
89
|
+
sseHeartbeatMs?: number;
|
|
48
90
|
}
|
|
49
91
|
|
|
50
92
|
export class WebHost {
|
|
@@ -52,6 +94,10 @@ export class WebHost {
|
|
|
52
94
|
private readonly token: Buffer;
|
|
53
95
|
private readonly adapter: PiWebAdapter;
|
|
54
96
|
private readonly clients = new Set<ServerResponse>();
|
|
97
|
+
private readonly clientHeartbeats = new Map<
|
|
98
|
+
ServerResponse,
|
|
99
|
+
ReturnType<typeof setInterval>
|
|
100
|
+
>();
|
|
55
101
|
private readonly events: WebEvent[] = [];
|
|
56
102
|
private sequence = 0;
|
|
57
103
|
private port = 0;
|
|
@@ -63,11 +109,16 @@ export class WebHost {
|
|
|
63
109
|
WebHostOptions["directoryChooser"]
|
|
64
110
|
>;
|
|
65
111
|
private readonly shutdownTimeoutMs: number;
|
|
112
|
+
private readonly sseHeartbeatMs: number;
|
|
66
113
|
private readonly unsubscribeCapabilities: () => void;
|
|
67
114
|
private readonly unsubscribeRuntime: () => void;
|
|
68
115
|
private readonly chooserAbort = new AbortController();
|
|
69
116
|
private readonly leaseSensitiveRequests = new Set<Promise<void>>();
|
|
70
117
|
private readonly leaseSensitiveMessages = new Set<IncomingMessage>();
|
|
118
|
+
private readonly promptAdmissions = new Map<
|
|
119
|
+
string,
|
|
120
|
+
PromptAdmission
|
|
121
|
+
>();
|
|
71
122
|
private stopping = false;
|
|
72
123
|
private stopPromise?: Promise<void>;
|
|
73
124
|
|
|
@@ -90,6 +141,14 @@ export class WebHost {
|
|
|
90
141
|
) {
|
|
91
142
|
throw new Error("Web host shutdown timeout must be a positive integer");
|
|
92
143
|
}
|
|
144
|
+
this.sseHeartbeatMs =
|
|
145
|
+
options.sseHeartbeatMs ?? DEFAULT_SSE_HEARTBEAT_MS;
|
|
146
|
+
if (
|
|
147
|
+
!Number.isSafeInteger(this.sseHeartbeatMs) ||
|
|
148
|
+
this.sseHeartbeatMs <= 0
|
|
149
|
+
) {
|
|
150
|
+
throw new Error("SSE heartbeat interval must be a positive integer");
|
|
151
|
+
}
|
|
93
152
|
this.adapter = new PiWebAdapter(options.runtime);
|
|
94
153
|
this.onEvent = options.onEvent;
|
|
95
154
|
this.unsubscribeCapabilities = subscribeWebCapabilities((scope) => {
|
|
@@ -185,8 +244,7 @@ export class WebHost {
|
|
|
185
244
|
client.writableLength > MAX_SSE_BUFFER_BYTES ||
|
|
186
245
|
!client.write(record)
|
|
187
246
|
) {
|
|
188
|
-
this.
|
|
189
|
-
client.destroy();
|
|
247
|
+
this.removeSseClient(client, "destroy");
|
|
190
248
|
}
|
|
191
249
|
}
|
|
192
250
|
this.onEvent?.(event.type, event.detail);
|
|
@@ -204,8 +262,7 @@ export class WebHost {
|
|
|
204
262
|
this.unsubscribeCapabilities();
|
|
205
263
|
this.unsubscribeRuntime();
|
|
206
264
|
this.chooserAbort.abort();
|
|
207
|
-
for (const client of this.clients) client
|
|
208
|
-
this.clients.clear();
|
|
265
|
+
for (const client of [...this.clients]) this.removeSseClient(client, "end");
|
|
209
266
|
const closeServer = this.server.listening
|
|
210
267
|
? new Promise<void>((resolve) => {
|
|
211
268
|
const forceClose = setTimeout(
|
|
@@ -266,6 +323,15 @@ export class WebHost {
|
|
|
266
323
|
await this.handle(request, response);
|
|
267
324
|
} catch (error) {
|
|
268
325
|
if (response.destroyed || response.writableEnded) return;
|
|
326
|
+
if (error instanceof WebRequestError) {
|
|
327
|
+
return this.json(response, error.statusCode, {
|
|
328
|
+
code: error.code,
|
|
329
|
+
error: error.message,
|
|
330
|
+
...(error.maxBytes === undefined
|
|
331
|
+
? {}
|
|
332
|
+
: { maxBytes: error.maxBytes }),
|
|
333
|
+
});
|
|
334
|
+
}
|
|
269
335
|
this.json(response, 500, {
|
|
270
336
|
error: error instanceof Error ? error.message : "request failed",
|
|
271
337
|
});
|
|
@@ -276,6 +342,7 @@ export class WebHost {
|
|
|
276
342
|
if (request.method === "GET" || request.method === "HEAD") return false;
|
|
277
343
|
const pathname = new URL(request.url ?? "/", `http://${HOST}`).pathname;
|
|
278
344
|
if (pathname === "/api/prompt") return false;
|
|
345
|
+
if (pathname === "/api/turns/cancel") return true;
|
|
279
346
|
return pathname.startsWith("/api/workspaces") ||
|
|
280
347
|
pathname.startsWith("/api/sessions") ||
|
|
281
348
|
pathname === "/api/model";
|
|
@@ -486,6 +553,51 @@ export class WebHost {
|
|
|
486
553
|
error: "prompt must be 1-12000 characters",
|
|
487
554
|
});
|
|
488
555
|
}
|
|
556
|
+
const commandId =
|
|
557
|
+
typeof body.commandId === "string" && body.commandId.length > 0
|
|
558
|
+
? body.commandId
|
|
559
|
+
: randomUUID();
|
|
560
|
+
if (commandId.length > 128) {
|
|
561
|
+
return this.json(response, 400, {
|
|
562
|
+
error: "commandId must be at most 128 characters",
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
if (body.retry !== undefined && typeof body.retry !== "boolean") {
|
|
566
|
+
return this.json(response, 400, {
|
|
567
|
+
error: "retry must be a boolean when provided",
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
if (typeof body.sessionId !== "string") {
|
|
571
|
+
return this.json(response, 400, {
|
|
572
|
+
error: "sessionId is required",
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
const existing = this.promptAdmissions.get(commandId);
|
|
576
|
+
if (existing) {
|
|
577
|
+
if (
|
|
578
|
+
existing.sessionId !== body.sessionId ||
|
|
579
|
+
existing.content !== content
|
|
580
|
+
) {
|
|
581
|
+
return this.json(response, 409, {
|
|
582
|
+
code: "COMMAND_CONFLICT",
|
|
583
|
+
error: "commandId is already bound to a different prompt",
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
const result = await existing.completion;
|
|
587
|
+
traceWeb("prompt_admission_replayed", {
|
|
588
|
+
commandId,
|
|
589
|
+
sessionId: body.sessionId,
|
|
590
|
+
status: result.status,
|
|
591
|
+
elapsedMs: elapsed(requestStarted),
|
|
592
|
+
});
|
|
593
|
+
return this.json(response, result.status, result.body);
|
|
594
|
+
}
|
|
595
|
+
if (body.retry === true) {
|
|
596
|
+
return this.json(response, 409, {
|
|
597
|
+
code: "COMMAND_ADMISSION_UNKNOWN",
|
|
598
|
+
error: "previous prompt admission is unknown; refresh canonical state before sending a new request",
|
|
599
|
+
});
|
|
600
|
+
}
|
|
489
601
|
if (this.runtime.workspaceSelected !== true) {
|
|
490
602
|
return this.json(response, 409, {
|
|
491
603
|
code: "WORKSPACE_REQUIRED",
|
|
@@ -493,7 +605,6 @@ export class WebHost {
|
|
|
493
605
|
});
|
|
494
606
|
}
|
|
495
607
|
if (
|
|
496
|
-
typeof body.sessionId !== "string" ||
|
|
497
608
|
body.sessionId !== this.runtime.sessionManager.getSessionId()
|
|
498
609
|
) {
|
|
499
610
|
return this.json(response, 409, {
|
|
@@ -501,43 +612,61 @@ export class WebHost {
|
|
|
501
612
|
error: "Only the active Web session accepts messages",
|
|
502
613
|
});
|
|
503
614
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
chars: content.length,
|
|
509
|
-
});
|
|
510
|
-
try {
|
|
511
|
-
await this.runtime.sendPrompt(content, {
|
|
512
|
-
commandId,
|
|
513
|
-
expectedSessionId: body.sessionId,
|
|
615
|
+
if (!this.makePromptAdmissionSpace()) {
|
|
616
|
+
return this.json(response, 503, {
|
|
617
|
+
code: "PROMPT_ADMISSION_CAPACITY",
|
|
618
|
+
error: "prompt admission capacity is full; wait for a pending admission to settle",
|
|
514
619
|
});
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
620
|
+
}
|
|
621
|
+
const admission = this.beginPromptAdmission(
|
|
622
|
+
commandId,
|
|
623
|
+
body.sessionId,
|
|
624
|
+
content,
|
|
625
|
+
);
|
|
626
|
+
const result = await admission.completion;
|
|
627
|
+
return this.json(response, result.status, result.body);
|
|
628
|
+
}
|
|
629
|
+
if (url.pathname === "/api/turns/cancel" && request.method === "POST") {
|
|
630
|
+
const body = await this.readJson(request);
|
|
631
|
+
if (
|
|
632
|
+
typeof body.sessionId !== "string" ||
|
|
633
|
+
body.sessionId.length === 0 ||
|
|
634
|
+
body.sessionId.length > 128 ||
|
|
635
|
+
typeof body.commandId !== "string" ||
|
|
636
|
+
body.commandId.length === 0 ||
|
|
637
|
+
body.commandId.length > 128 ||
|
|
638
|
+
typeof body.epoch !== "number" ||
|
|
639
|
+
!Number.isSafeInteger(body.epoch) ||
|
|
640
|
+
body.epoch <= 0
|
|
641
|
+
) {
|
|
642
|
+
return this.json(response, 400, {
|
|
643
|
+
code: "INVALID_TURN",
|
|
644
|
+
error: "bounded sessionId, commandId, and positive turn epoch are required",
|
|
525
645
|
});
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
646
|
+
}
|
|
647
|
+
if (this.runtime.workspaceSelected !== true) {
|
|
648
|
+
return this.json(response, 409, {
|
|
649
|
+
code: "WORKSPACE_REQUIRED",
|
|
650
|
+
error: "Choose a workspace before using the Web runtime",
|
|
529
651
|
});
|
|
530
652
|
}
|
|
531
|
-
|
|
532
|
-
traceWeb("prompt_response_sent", {
|
|
533
|
-
commandId,
|
|
653
|
+
const result = await this.runtime.cancelTurn({
|
|
534
654
|
sessionId: body.sessionId,
|
|
535
|
-
|
|
655
|
+
commandId: body.commandId,
|
|
656
|
+
epoch: body.epoch,
|
|
536
657
|
});
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
accepted
|
|
540
|
-
|
|
658
|
+
traceWeb("turn_cancel_receipt", { ...result });
|
|
659
|
+
const status =
|
|
660
|
+
result.state === "accepted"
|
|
661
|
+
? 202
|
|
662
|
+
: result.state === "already-settled"
|
|
663
|
+
? 200
|
|
664
|
+
: result.state === "failed"
|
|
665
|
+
? 500
|
|
666
|
+
: 409;
|
|
667
|
+
return this.json(response, status, {
|
|
668
|
+
...result,
|
|
669
|
+
accepted: result.state === "accepted",
|
|
541
670
|
cursor: this.sequence,
|
|
542
671
|
});
|
|
543
672
|
}
|
|
@@ -557,6 +686,19 @@ export class WebHost {
|
|
|
557
686
|
}
|
|
558
687
|
if (url.pathname === "/api/models")
|
|
559
688
|
return this.json(response, 200, { models: this.runtime.listModels() });
|
|
689
|
+
if (url.pathname === "/api/capabilities")
|
|
690
|
+
return this.json(response, 200, {
|
|
691
|
+
sessionId: this.runtime.sessionManager.getSessionId(),
|
|
692
|
+
capabilities: webCapabilitySnapshot(this.runtime.sessionManager),
|
|
693
|
+
});
|
|
694
|
+
if (url.pathname === "/api/diagnostics")
|
|
695
|
+
return this.json(response, 200, {
|
|
696
|
+
node: process.version,
|
|
697
|
+
cwd: this.runtime.cwd,
|
|
698
|
+
sessionId: this.runtime.sessionManager.getSessionId(),
|
|
699
|
+
workspaceSelected: this.runtime.workspaceSelected,
|
|
700
|
+
models: this.runtime.listModels().filter((model) => model.current),
|
|
701
|
+
});
|
|
560
702
|
if (url.pathname === "/api/snapshot") {
|
|
561
703
|
const cursor = this.sequence;
|
|
562
704
|
const projection = await this.adapter.getSnapshot(
|
|
@@ -566,6 +708,7 @@ export class WebHost {
|
|
|
566
708
|
protocolVersion: WEB_PROTOCOL_VERSION,
|
|
567
709
|
generatedAt: new Date().toISOString(),
|
|
568
710
|
cursor,
|
|
711
|
+
preferences: { theme: loadSetupConfig().ui.webTheme },
|
|
569
712
|
...projection,
|
|
570
713
|
};
|
|
571
714
|
let finalBytes = jsonByteLength(snapshot);
|
|
@@ -627,29 +770,142 @@ export class WebHost {
|
|
|
627
770
|
}
|
|
628
771
|
}
|
|
629
772
|
|
|
773
|
+
private makePromptAdmissionSpace() {
|
|
774
|
+
while (this.promptAdmissions.size >= MAX_PROMPT_ADMISSIONS) {
|
|
775
|
+
const settled = [...this.promptAdmissions.entries()].find(
|
|
776
|
+
([, admission]) => admission.result !== undefined,
|
|
777
|
+
);
|
|
778
|
+
if (!settled) return false;
|
|
779
|
+
this.promptAdmissions.delete(settled[0]);
|
|
780
|
+
}
|
|
781
|
+
return true;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
private beginPromptAdmission(
|
|
785
|
+
commandId: string,
|
|
786
|
+
sessionId: string,
|
|
787
|
+
content: string,
|
|
788
|
+
) {
|
|
789
|
+
let settle!: (result: PromptAdmissionResponse) => void;
|
|
790
|
+
const admission: PromptAdmission = {
|
|
791
|
+
sessionId,
|
|
792
|
+
content,
|
|
793
|
+
completion: new Promise<PromptAdmissionResponse>((resolve) => {
|
|
794
|
+
settle = resolve;
|
|
795
|
+
}),
|
|
796
|
+
};
|
|
797
|
+
// Store before dispatch: a client retry can only replay this record.
|
|
798
|
+
this.promptAdmissions.set(commandId, admission);
|
|
799
|
+
try {
|
|
800
|
+
traceWeb("prompt_received", {
|
|
801
|
+
commandId,
|
|
802
|
+
sessionId,
|
|
803
|
+
chars: content.length,
|
|
804
|
+
});
|
|
805
|
+
} catch {}
|
|
806
|
+
void Promise.resolve()
|
|
807
|
+
.then(() =>
|
|
808
|
+
this.runtime.sendPrompt(content, {
|
|
809
|
+
commandId,
|
|
810
|
+
expectedSessionId: sessionId,
|
|
811
|
+
}),
|
|
812
|
+
)
|
|
813
|
+
.then(
|
|
814
|
+
(receipt) => {
|
|
815
|
+
const result: PromptAdmissionResponse = {
|
|
816
|
+
status: 202,
|
|
817
|
+
body: {
|
|
818
|
+
id: commandId,
|
|
819
|
+
accepted: true,
|
|
820
|
+
state: "accepted",
|
|
821
|
+
pendingFollowUps: receipt.pendingFollowUps,
|
|
822
|
+
cursor: this.sequence,
|
|
823
|
+
},
|
|
824
|
+
};
|
|
825
|
+
try {
|
|
826
|
+
this.publish("prompt_accepted", {
|
|
827
|
+
commandId,
|
|
828
|
+
sessionId,
|
|
829
|
+
pendingFollowUps: receipt.pendingFollowUps,
|
|
830
|
+
});
|
|
831
|
+
result.body.cursor = this.sequence;
|
|
832
|
+
} catch {}
|
|
833
|
+
return result;
|
|
834
|
+
},
|
|
835
|
+
(error) => {
|
|
836
|
+
const failure = this.runtimeRequestFailure(error);
|
|
837
|
+
return {
|
|
838
|
+
status: failure.status,
|
|
839
|
+
body: { code: failure.code, error: failure.error },
|
|
840
|
+
};
|
|
841
|
+
},
|
|
842
|
+
)
|
|
843
|
+
.then((result: PromptAdmissionResponse) => {
|
|
844
|
+
admission.result = result;
|
|
845
|
+
settle(result);
|
|
846
|
+
try {
|
|
847
|
+
traceWeb(
|
|
848
|
+
result.status === 202
|
|
849
|
+
? "prompt_admission_finished"
|
|
850
|
+
: "prompt_admission_failed",
|
|
851
|
+
{
|
|
852
|
+
commandId,
|
|
853
|
+
sessionId,
|
|
854
|
+
status: result.status,
|
|
855
|
+
...(typeof result.body.error === "string"
|
|
856
|
+
? { error: result.body.error }
|
|
857
|
+
: {}),
|
|
858
|
+
},
|
|
859
|
+
);
|
|
860
|
+
} catch {}
|
|
861
|
+
})
|
|
862
|
+
.catch((error) => {
|
|
863
|
+
if (admission.result) return;
|
|
864
|
+
const failure = this.runtimeRequestFailure(error);
|
|
865
|
+
const result: PromptAdmissionResponse = {
|
|
866
|
+
status: failure.status,
|
|
867
|
+
body: { code: failure.code, error: failure.error },
|
|
868
|
+
};
|
|
869
|
+
admission.result = result;
|
|
870
|
+
settle(result);
|
|
871
|
+
});
|
|
872
|
+
return admission;
|
|
873
|
+
}
|
|
874
|
+
|
|
630
875
|
private async readJson(request: IncomingMessage) {
|
|
631
876
|
const chunks: Buffer[] = [];
|
|
632
877
|
let bytes = 0;
|
|
633
878
|
for await (const chunk of request) {
|
|
634
879
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
635
880
|
bytes += buffer.length;
|
|
636
|
-
if (bytes > MAX_COMMAND_BYTES)
|
|
637
|
-
throw new
|
|
881
|
+
if (bytes > MAX_COMMAND_BYTES) {
|
|
882
|
+
throw new WebRequestError(
|
|
883
|
+
"request body is too large",
|
|
884
|
+
"REQUEST_BODY_TOO_LARGE",
|
|
885
|
+
413,
|
|
886
|
+
MAX_COMMAND_BYTES,
|
|
887
|
+
);
|
|
888
|
+
}
|
|
638
889
|
chunks.push(buffer);
|
|
639
890
|
}
|
|
891
|
+
let value: unknown;
|
|
640
892
|
try {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
throw new Error(
|
|
648
|
-
error instanceof SyntaxError
|
|
649
|
-
? "request body is invalid JSON"
|
|
650
|
-
: String(error),
|
|
893
|
+
value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
894
|
+
} catch {
|
|
895
|
+
throw new WebRequestError(
|
|
896
|
+
"request body is invalid JSON",
|
|
897
|
+
"INVALID_REQUEST_BODY",
|
|
898
|
+
400,
|
|
651
899
|
);
|
|
652
900
|
}
|
|
901
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
902
|
+
throw new WebRequestError(
|
|
903
|
+
"request body must be an object",
|
|
904
|
+
"INVALID_REQUEST_BODY",
|
|
905
|
+
400,
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
return value as Record<string, unknown>;
|
|
653
909
|
}
|
|
654
910
|
|
|
655
911
|
private authorized(request: IncomingMessage) {
|
|
@@ -736,7 +992,31 @@ export class WebHost {
|
|
|
736
992
|
// ordering without treating normal backpressure as a broken client.
|
|
737
993
|
for (const record of replay) response.write(record);
|
|
738
994
|
this.clients.add(response);
|
|
739
|
-
|
|
995
|
+
const heartbeat = setInterval(() => {
|
|
996
|
+
if (
|
|
997
|
+
response.destroyed ||
|
|
998
|
+
response.writableEnded ||
|
|
999
|
+
response.writableLength > MAX_SSE_BUFFER_BYTES ||
|
|
1000
|
+
!response.write(": heartbeat\n\n")
|
|
1001
|
+
) {
|
|
1002
|
+
this.removeSseClient(response, "destroy");
|
|
1003
|
+
}
|
|
1004
|
+
}, this.sseHeartbeatMs);
|
|
1005
|
+
heartbeat.unref();
|
|
1006
|
+
this.clientHeartbeats.set(response, heartbeat);
|
|
1007
|
+
response.on("close", () => this.removeSseClient(response));
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
private removeSseClient(
|
|
1011
|
+
response: ServerResponse,
|
|
1012
|
+
close?: "destroy" | "end",
|
|
1013
|
+
) {
|
|
1014
|
+
this.clients.delete(response);
|
|
1015
|
+
const heartbeat = this.clientHeartbeats.get(response);
|
|
1016
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
1017
|
+
this.clientHeartbeats.delete(response);
|
|
1018
|
+
if (close === "destroy" && !response.destroyed) response.destroy();
|
|
1019
|
+
else if (close === "end" && !response.writableEnded) response.end();
|
|
740
1020
|
}
|
|
741
1021
|
|
|
742
1022
|
private parseCursor(value: string | undefined | null) {
|
package/web/protocol/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { WebCapabilitySnapshot } from "../../extensions/shared/web-observer-registry.ts";
|
|
3
|
+
import type { WebActiveTurn } from "../runtime/types.ts";
|
|
3
4
|
|
|
4
5
|
export const WEB_PROTOCOL_VERSION = 1;
|
|
5
6
|
export const WEB_MAX_EVENTS = 200;
|
|
@@ -105,6 +106,9 @@ export interface WebSnapshot {
|
|
|
105
106
|
protocolVersion: typeof WEB_PROTOCOL_VERSION;
|
|
106
107
|
generatedAt: string;
|
|
107
108
|
cursor: number;
|
|
109
|
+
preferences: {
|
|
110
|
+
theme: "system" | "light" | "dark";
|
|
111
|
+
};
|
|
108
112
|
/** Absent until the browser selects or creates a real Web Session. */
|
|
109
113
|
currentSessionId?: string;
|
|
110
114
|
workspaces: WebWorkspaceSummary[];
|
|
@@ -113,6 +117,7 @@ export interface WebSnapshot {
|
|
|
113
117
|
models: WebModelSummary[];
|
|
114
118
|
runtime: {
|
|
115
119
|
status: "idle" | "running" | "unknown";
|
|
120
|
+
activeTurn?: WebActiveTurn;
|
|
116
121
|
capabilities: WebCapabilitySnapshot;
|
|
117
122
|
};
|
|
118
123
|
truncation: WebSnapshotTruncation;
|