pi-open-deskos 0.3.0 → 0.4.0
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 +86 -17
- package/README.zh-CN.md +74 -17
- package/fixtures/control-v2.json +2 -2
- package/package.json +7 -6
- package/session-control +38 -0
- package/src/config.ts +135 -15
- package/src/console-client.ts +47 -21
- package/src/console-extension.ts +84 -43
- package/src/control-transport.ts +3 -3
- package/src/desk-set.ts +69 -0
- package/src/events.ts +60 -17
- package/src/index.ts +118 -30
- package/src/processes.ts +117 -13
- package/src/reporter.ts +117 -39
- package/src/session-host-client.mjs +274 -0
- package/src/session-host.ts +632 -0
- package/src/transport.ts +34 -11
- package/src/types.ts +12 -9
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { chmod, lstat, mkdir, open, readFile, realpath, rename, stat, unlink } from "node:fs/promises";
|
|
4
|
+
import { readdirSync } from "node:fs";
|
|
5
|
+
import { createConnection, createServer, type Server, type Socket } from "node:net";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { dirname, isAbsolute, posix, relative, sep, win32 } from "node:path";
|
|
8
|
+
import { StringDecoder } from "node:string_decoder";
|
|
9
|
+
import { boundEventText, eventsFromMessage, promptFromMessage } from "./events.ts";
|
|
10
|
+
import type { HostedPiTurnOutcome } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Make the session this process is drivable by a desk's voice agent, from inside that same
|
|
14
|
+
* process.
|
|
15
|
+
*
|
|
16
|
+
* A reported session is read-only, and the only session a desk's voice agent can drive today is
|
|
17
|
+
* a Hosted Pi that a separate daemon process started. This host is the third path: no new
|
|
18
|
+
* process, no second credential, and nothing reachable over the network. The desk reaches it
|
|
19
|
+
* over the user's own SSH session, and ownership of the file is the only gate.
|
|
20
|
+
*
|
|
21
|
+
* The wire contract is `integrations/personal-bot/src/task-protocol.mjs` in the Open DeskOS
|
|
22
|
+
* repository: version 1, one bounded JSON line in, one correlated frame out, one frame per
|
|
23
|
+
* connection, and a connection that sends no line closed without a frame. It is spoken here
|
|
24
|
+
* rather than forked, and the Hosted Pi descriptor (`hosted-pi/endpoint.json`) is deliberately
|
|
25
|
+
* never written, because a desk reads that path as a session host it can also start and end,
|
|
26
|
+
* and this host can do neither.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** The protocol's own version and bounds, as `task-protocol.mjs` fixes them. */
|
|
30
|
+
export const SESSION_PROTOCOL_VERSION = 1;
|
|
31
|
+
export const SESSION_REQUEST_LIMIT = 64 * 1024;
|
|
32
|
+
export const SESSION_RESPONSE_LIMIT = 256 * 1024;
|
|
33
|
+
export const SESSION_REQUEST_TIMEOUT = 8000;
|
|
34
|
+
|
|
35
|
+
/** The protocol's own refusal vocabulary, so a desk reads what it already reads. */
|
|
36
|
+
const PROTOCOL_INVALID = "任务协议无效";
|
|
37
|
+
const REQUEST_TOO_LARGE = "请求过大";
|
|
38
|
+
const PROJECT_NOT_ADMITTED = "项目不在允许的开发目录内";
|
|
39
|
+
const UNKNOWN_COMMAND = "未知任务命令";
|
|
40
|
+
const TASK_NOT_FOUND = "未找到任务";
|
|
41
|
+
const PROMPT_INVALID = "任务 ID 或提示无效";
|
|
42
|
+
// Native submission has no identity-aware acknowledgment in the public Pi API.
|
|
43
|
+
const MUTATION_CONFLICT = "任务 ID 已用于不同请求";
|
|
44
|
+
const MUTATION_LIMIT = 256;
|
|
45
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
46
|
+
/** One fixed reason for the four verbs a session host cannot serve at all. */
|
|
47
|
+
export const NOT_A_SESSION_HOST = "此端点是当前 Pi 会话,不是 Hosted Pi 任务服务";
|
|
48
|
+
|
|
49
|
+
/** Where this session answers, once it is bound. */
|
|
50
|
+
export interface SessionEndpointPaths {
|
|
51
|
+
socketPath: string;
|
|
52
|
+
descriptorPath: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SessionEndpoint extends SessionEndpointPaths {
|
|
56
|
+
/** False when the socket is served but the desk cannot find it. */
|
|
57
|
+
published: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The outcome of a bind, so a caller can see a refusal instead of a silent endpoint. */
|
|
61
|
+
export interface SessionHostStart {
|
|
62
|
+
bound: boolean;
|
|
63
|
+
socketPath: string;
|
|
64
|
+
descriptorPath: string;
|
|
65
|
+
published: boolean;
|
|
66
|
+
reason?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type SessionHostState = "running" | "settled";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One session as a desk's voice agent reads it, in the same record shape a Hosted Pi answers
|
|
73
|
+
* with, so one desk reads both without a second vocabulary. Nothing here is inferred: a field is
|
|
74
|
+
* present only once it has been observed.
|
|
75
|
+
*/
|
|
76
|
+
export interface SessionHostTask {
|
|
77
|
+
taskId: string;
|
|
78
|
+
project: string;
|
|
79
|
+
name?: string;
|
|
80
|
+
lifecycle: "live";
|
|
81
|
+
state: SessionHostState;
|
|
82
|
+
activity: "working" | "idle";
|
|
83
|
+
turnOutcome?: HostedPiTurnOutcome;
|
|
84
|
+
/** The last thing this session was asked. */
|
|
85
|
+
prompt: string;
|
|
86
|
+
/** The most recent assistant text, when there is one. */
|
|
87
|
+
response: string;
|
|
88
|
+
/** What this session is doing right now, as the session card already names it. */
|
|
89
|
+
latestActivity?: string;
|
|
90
|
+
/** This host runs no verification step of its own. */
|
|
91
|
+
verification: "not_run";
|
|
92
|
+
lastDelivery?: { mutationId?: string; state: "unknown"; submitted: true; prompt: string };
|
|
93
|
+
deliveries?: Array<{ mutationId: string; state: "unknown"; submitted: true }>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface SessionHostOptions {
|
|
97
|
+
pi: Pick<ExtensionAPI, "sendUserMessage">;
|
|
98
|
+
env?: NodeJS.ProcessEnv;
|
|
99
|
+
platform?: NodeJS.Platform;
|
|
100
|
+
/** The protocol's own request timeout, overridable so a test need not wait eight seconds. */
|
|
101
|
+
requestTimeoutMs?: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The one absolute path a desk and this session can both derive without configuring anything:
|
|
106
|
+
* the session's runtime directory, which on Linux is the one the session manager itself owns.
|
|
107
|
+
* The path flavour follows the platform this session runs on, so a Windows host gets a Windows
|
|
108
|
+
* path from the same rule rather than a POSIX one.
|
|
109
|
+
*/
|
|
110
|
+
export function sessionRuntimeDir(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): string {
|
|
111
|
+
const flavour = platform === "win32" ? win32 : posix;
|
|
112
|
+
const runtimeDir = env.XDG_RUNTIME_DIR;
|
|
113
|
+
if (typeof runtimeDir === "string" && runtimeDir.length > 0 && flavour.isAbsolute(runtimeDir)) return runtimeDir;
|
|
114
|
+
if (platform === "win32") return env.LOCALAPPDATA && env.LOCALAPPDATA.length > 0 ? env.LOCALAPPDATA : flavour.join(homedir(), "AppData", "Local");
|
|
115
|
+
return flavour.join(homedir(), ".local", "run");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Where this session listens, and where it publishes that fact, both named after this
|
|
120
|
+
* session so two sessions on one machine never collide. A single fixed endpoint per host
|
|
121
|
+
* served whichever session bound it first and refused every later one, which is the
|
|
122
|
+
* opposite of what a desk needs when the operator has several sessions and means one of
|
|
123
|
+
* them by its project. The descriptor sits beside the socket, so a desk finds every
|
|
124
|
+
* session's own path without reading this process's configuration.
|
|
125
|
+
*/
|
|
126
|
+
export function resolveSessionEndpoint(
|
|
127
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
128
|
+
platform: NodeJS.Platform = process.platform,
|
|
129
|
+
sessionId: string,
|
|
130
|
+
): SessionEndpointPaths {
|
|
131
|
+
const runtimeDir = sessionRuntimeDir(env, platform);
|
|
132
|
+
const override = env.ODK_SESSION_HOST_SOCKET ?? "";
|
|
133
|
+
const flavour = platform === "win32" ? win32 : posix;
|
|
134
|
+
// The variable names the directory, which is what keeps two sessions apart inside it.
|
|
135
|
+
const directory = flavour.isAbsolute(override) ? override : flavour.join(runtimeDir, "odk", "sessions");
|
|
136
|
+
return {
|
|
137
|
+
socketPath: flavour.join(directory, `${sessionId}.sock`),
|
|
138
|
+
descriptorPath: flavour.join(directory, `${sessionId}.json`),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isRequest(value: unknown): value is Record<string, unknown> {
|
|
143
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
144
|
+
const request = value as Record<string, unknown>;
|
|
145
|
+
return request.version === SESSION_PROTOCOL_VERSION
|
|
146
|
+
&& typeof request.requestId === "string"
|
|
147
|
+
&& request.requestId.length > 0
|
|
148
|
+
&& request.requestId.length <= 128;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The protocol's own text rule: bounded bytes that survive a UTF-8 round trip. */
|
|
152
|
+
function validText(value: unknown, limit: number): value is string {
|
|
153
|
+
return typeof value === "string" && Buffer.byteLength(value) <= limit && Buffer.from(value).toString("utf8") === value;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function within(root: string, path: string): boolean {
|
|
157
|
+
const suffix = relative(root, path);
|
|
158
|
+
return suffix === "" || (suffix !== ".." && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function privateDirectory(directory: string): Promise<void> {
|
|
162
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
163
|
+
// Best effort: a platform without mode bits has nothing to enforce here.
|
|
164
|
+
await chmod(directory, 0o700).catch(() => {});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** True when something is still listening at the path, which is what makes a socket live. */
|
|
168
|
+
function liveSocket(socketPath: string): Promise<boolean> {
|
|
169
|
+
return new Promise((resolve, reject) => {
|
|
170
|
+
const probe = createConnection(socketPath);
|
|
171
|
+
const timer = setTimeout(() => { probe.destroy(); reject(new Error("无法确定任务服务是否运行")); }, 1000);
|
|
172
|
+
probe.once("connect", () => { clearTimeout(timer); probe.destroy(); resolve(true); });
|
|
173
|
+
probe.once("error", (error: NodeJS.ErrnoException) => {
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
probe.destroy();
|
|
176
|
+
if (error.code === "ECONNREFUSED") resolve(false);
|
|
177
|
+
else reject(error);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A socket file left by a session that is gone is removed before this one binds; one that another
|
|
184
|
+
* live host still owns is never taken over, because two hosts answering on one path would each
|
|
185
|
+
* look like half an answer.
|
|
186
|
+
*/
|
|
187
|
+
async function clearStaleSocket(socketPath: string): Promise<void> {
|
|
188
|
+
let info;
|
|
189
|
+
try {
|
|
190
|
+
info = await lstat(socketPath);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
if (!info.isSocket() || info.uid !== process.getuid?.()) throw new Error("任务套接字路径已占用");
|
|
196
|
+
if (await liveSocket(socketPath)) throw new Error("任务服务已运行");
|
|
197
|
+
await unlink(socketPath);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Remove this host's own socket once nothing answers at it any more. */
|
|
201
|
+
async function removeOwnSocket(socketPath: string): Promise<void> {
|
|
202
|
+
try {
|
|
203
|
+
const info = await lstat(socketPath);
|
|
204
|
+
if (!info.isSocket() || info.uid !== process.getuid?.()) return;
|
|
205
|
+
if (await liveSocket(socketPath)) return;
|
|
206
|
+
await unlink(socketPath);
|
|
207
|
+
} catch {
|
|
208
|
+
// A socket that is already gone is the state this wants.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export class SessionHost {
|
|
213
|
+
readonly #pi: Pick<ExtensionAPI, "sendUserMessage">;
|
|
214
|
+
readonly #env: NodeJS.ProcessEnv;
|
|
215
|
+
readonly #platform: NodeJS.Platform;
|
|
216
|
+
#paths: SessionEndpointPaths;
|
|
217
|
+
readonly #timeoutMs: number;
|
|
218
|
+
readonly #sockets = new Set<Socket>();
|
|
219
|
+
/** Serializes bind and unbind, so a reload cannot leave two hosts on one path. */
|
|
220
|
+
#lifecycle: Promise<void> = Promise.resolve();
|
|
221
|
+
#ctx: ExtensionContext | null = null;
|
|
222
|
+
#server: Server | null = null;
|
|
223
|
+
#bound: SessionEndpoint | null = null;
|
|
224
|
+
#realProject = "";
|
|
225
|
+
#sessionId = "";
|
|
226
|
+
#state: SessionHostState = "settled";
|
|
227
|
+
#outcome: HostedPiTurnOutcome | undefined = undefined;
|
|
228
|
+
/** Whether an assistant message has already stated how the current turn ended. */
|
|
229
|
+
#observed = false;
|
|
230
|
+
#cancelRequested = false;
|
|
231
|
+
/** Set when a bind replaced a session whose turn was still running here. */
|
|
232
|
+
#interrupted = false;
|
|
233
|
+
#goal = "";
|
|
234
|
+
#response = "";
|
|
235
|
+
#latestActivity: string | undefined = undefined;
|
|
236
|
+
readonly #mutations = new Map<string, { payload: string; result: Promise<Record<string, unknown>> }>();
|
|
237
|
+
readonly #offered: Array<{ mutationId?: string; state: "unknown"; submitted: true; prompt: string }> = [];
|
|
238
|
+
#lastDelivery: { mutationId?: string; state: "unknown"; submitted: true; prompt: string } | undefined;
|
|
239
|
+
|
|
240
|
+
constructor(options: SessionHostOptions) {
|
|
241
|
+
this.#pi = options.pi;
|
|
242
|
+
this.#env = options.env ?? process.env;
|
|
243
|
+
this.#platform = options.platform ?? process.platform;
|
|
244
|
+
this.#paths = resolveSessionEndpoint(this.#env, this.#platform, "unbound");
|
|
245
|
+
this.#timeoutMs = options.requestTimeoutMs ?? SESSION_REQUEST_TIMEOUT;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Where this session answers, or null while it is not bound. */
|
|
249
|
+
get endpoint(): SessionEndpoint | null {
|
|
250
|
+
return this.#bound;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Bind the endpoint for the session that is starting, replacing any previous bind. A reload
|
|
255
|
+
* arrives here like every other session start, and a failure is a returned reason rather than
|
|
256
|
+
* a thrown error, because a session must survive a machine that cannot spare an endpoint.
|
|
257
|
+
*/
|
|
258
|
+
start(ctx: ExtensionContext): Promise<SessionHostStart> {
|
|
259
|
+
const bind = this.#lifecycle.then(() => this.#bind(ctx), () => this.#bind(ctx));
|
|
260
|
+
this.#lifecycle = bind.then(() => {}, () => {});
|
|
261
|
+
return bind;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Close the endpoint. Idempotent, and safe before any bind or after a failed one. */
|
|
265
|
+
close(): Promise<void> {
|
|
266
|
+
const unbind = this.#lifecycle.then(() => this.#unbind(), () => this.#unbind());
|
|
267
|
+
this.#lifecycle = unbind.then(() => {}, () => {});
|
|
268
|
+
return unbind;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** `agent_start`: a turn is running now, so the previous outcome no longer answers for it. */
|
|
272
|
+
markRunning(): void {
|
|
273
|
+
this.#state = "running";
|
|
274
|
+
this.#outcome = undefined;
|
|
275
|
+
this.#observed = false;
|
|
276
|
+
this.#cancelRequested = false;
|
|
277
|
+
this.#interrupted = false;
|
|
278
|
+
this.#response = "";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** `agent_settled`: the turn ended. Only an abort this host asked for is claimed here. */
|
|
282
|
+
markSettled(): void {
|
|
283
|
+
this.#state = "settled";
|
|
284
|
+
if (!this.#observed && this.#cancelRequested) this.#outcome = "cancelled";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** `message_end`: the prompt that was sent, or the reply that came back. */
|
|
288
|
+
observeMessage(message: unknown): void {
|
|
289
|
+
const goal = promptFromMessage(message);
|
|
290
|
+
if (goal.length > 0) this.#goal = goal;
|
|
291
|
+
if ((message as { role?: unknown } | null)?.role !== "assistant") return;
|
|
292
|
+
for (const event of eventsFromMessage(message)) {
|
|
293
|
+
if (event.kind === "assistant") this.#response = event.text;
|
|
294
|
+
}
|
|
295
|
+
// The stop reason belongs to the message, whether or not it carried text: a turn the
|
|
296
|
+
// provider failed has no reply and must still be observed as failed.
|
|
297
|
+
const stopReason = (message as { stopReason?: unknown }).stopReason;
|
|
298
|
+
if (stopReason === "stop") this.#stateOutcome("finished");
|
|
299
|
+
else if (stopReason === "aborted") this.#stateOutcome("cancelled");
|
|
300
|
+
// A tool call, a pending, or a deferred message means the turn continues, so it says
|
|
301
|
+
// nothing about how the turn ends and nothing is claimed for it.
|
|
302
|
+
else if (typeof stopReason === "string" && !["toolUse", "pending", "deferred"].includes(stopReason)) this.#stateOutcome("failed");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** `tool_execution_start`: the newest thing the session is doing, as a short summary. */
|
|
306
|
+
observeActivity(activity: string): void {
|
|
307
|
+
const summary = boundEventText(activity);
|
|
308
|
+
if (summary.length > 0) this.#latestActivity = summary;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async #bind(ctx: ExtensionContext): Promise<SessionHostStart> {
|
|
312
|
+
await this.#unbind();
|
|
313
|
+
if (this.#platform === "win32") {
|
|
314
|
+
const paths = resolveSessionEndpoint(this.#env, this.#platform, ctx.sessionManager.getSessionId());
|
|
315
|
+
return { bound: false, ...paths, published: false, reason: "Windows 当前 Pi 会话控制尚未支持私有 IPC;Open DeskOS Link 报告与 Hosted Pi 控制仍可使用" };
|
|
316
|
+
}
|
|
317
|
+
const previousSessionId = this.#sessionId;
|
|
318
|
+
this.#interrupted = this.#state === "running";
|
|
319
|
+
if (previousSessionId !== ctx.sessionManager.getSessionId()) {
|
|
320
|
+
// A different session has never been asked or answered anything, so the previous
|
|
321
|
+
// session's goal and reply would be a claim about the wrong session.
|
|
322
|
+
this.#goal = "";
|
|
323
|
+
this.#response = "";
|
|
324
|
+
this.#latestActivity = undefined;
|
|
325
|
+
this.#mutations.clear();
|
|
326
|
+
this.#offered.length = 0;
|
|
327
|
+
this.#lastDelivery = undefined;
|
|
328
|
+
}
|
|
329
|
+
this.#ctx = ctx;
|
|
330
|
+
this.#sessionId = ctx.sessionManager.getSessionId();
|
|
331
|
+
// The endpoint belongs to this session, so it is resolved here rather than once for
|
|
332
|
+
// the process: a reload that switches sessions moves this session's own socket.
|
|
333
|
+
this.#paths = resolveSessionEndpoint(this.#env, this.#platform, this.#sessionId);
|
|
334
|
+
this.#state = ctx.isIdle() ? "settled" : "running";
|
|
335
|
+
this.#outcome = undefined;
|
|
336
|
+
this.#observed = false;
|
|
337
|
+
this.#cancelRequested = false;
|
|
338
|
+
this.#realProject = await this.#resolve(ctx.cwd);
|
|
339
|
+
const socketPath = this.#paths.socketPath;
|
|
340
|
+
try {
|
|
341
|
+
await privateDirectory(dirname(socketPath));
|
|
342
|
+
await clearStaleSocket(socketPath);
|
|
343
|
+
this.#server = await this.#listen(socketPath);
|
|
344
|
+
this.#bound = { ...this.#paths, published: false };
|
|
345
|
+
} catch (error) {
|
|
346
|
+
this.#ctx = null;
|
|
347
|
+
this.#server = null;
|
|
348
|
+
this.#bound = null;
|
|
349
|
+
return { bound: false, socketPath, descriptorPath: this.#paths.descriptorPath, published: false, reason: reasonOf(error) };
|
|
350
|
+
}
|
|
351
|
+
// A host that cannot publish keeps answering, and says so, rather than failing both the
|
|
352
|
+
// voice path and the discovery path over one file.
|
|
353
|
+
let published = false;
|
|
354
|
+
let reason: string | undefined;
|
|
355
|
+
try {
|
|
356
|
+
await this.#publish(socketPath);
|
|
357
|
+
published = true;
|
|
358
|
+
} catch (error) {
|
|
359
|
+
reason = reasonOf(error);
|
|
360
|
+
}
|
|
361
|
+
this.#bound = { ...this.#paths, published };
|
|
362
|
+
return { bound: true, socketPath, descriptorPath: this.#paths.descriptorPath, published, ...(reason === undefined ? {} : { reason }) };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async #unbind(): Promise<void> {
|
|
366
|
+
const server = this.#server;
|
|
367
|
+
const bound = this.#bound;
|
|
368
|
+
this.#server = null;
|
|
369
|
+
this.#bound = null;
|
|
370
|
+
this.#ctx = null;
|
|
371
|
+
for (const socket of this.#sockets) socket.destroy();
|
|
372
|
+
this.#sockets.clear();
|
|
373
|
+
if (server) {
|
|
374
|
+
await new Promise<void>((resolve) => { server.close(() => resolve()); });
|
|
375
|
+
}
|
|
376
|
+
if (!bound) return;
|
|
377
|
+
await this.#removeOwnDescriptor(bound.descriptorPath, bound.socketPath);
|
|
378
|
+
await removeOwnSocket(bound.socketPath);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Publish where this session listens, beside itself, so a desk never reads this process's configuration. */
|
|
382
|
+
async #publish(socketPath: string): Promise<void> {
|
|
383
|
+
const file = this.#paths.descriptorPath;
|
|
384
|
+
await privateDirectory(dirname(file));
|
|
385
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
386
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
387
|
+
try {
|
|
388
|
+
await handle.writeFile(JSON.stringify({
|
|
389
|
+
version: SESSION_PROTOCOL_VERSION,
|
|
390
|
+
sessionId: this.#sessionId,
|
|
391
|
+
socketPath,
|
|
392
|
+
project: this.#ctx?.cwd ?? "",
|
|
393
|
+
state: this.#state,
|
|
394
|
+
}));
|
|
395
|
+
await handle.sync();
|
|
396
|
+
} finally {
|
|
397
|
+
await handle.close();
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
await rename(temporary, file);
|
|
401
|
+
} finally {
|
|
402
|
+
await unlink(temporary).catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; });
|
|
403
|
+
}
|
|
404
|
+
await chmod(file, 0o600).catch(() => {});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Remove the descriptor only when it is still the one this bind wrote. */
|
|
408
|
+
async #removeOwnDescriptor(file: string, socketPath: string): Promise<void> {
|
|
409
|
+
try {
|
|
410
|
+
const descriptor = JSON.parse(await readFile(file, "utf8")) as { version?: unknown; socketPath?: unknown };
|
|
411
|
+
if (descriptor?.version !== SESSION_PROTOCOL_VERSION || descriptor.socketPath !== socketPath) return;
|
|
412
|
+
} catch {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
await unlink(file).catch(() => {});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Every session on this machine, as its own descriptor, so a desk can choose one. */
|
|
419
|
+
static listEndpoints(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): string[] {
|
|
420
|
+
const flavour = platform === "win32" ? win32 : posix;
|
|
421
|
+
const override = env.ODK_SESSION_HOST_SOCKET ?? "";
|
|
422
|
+
const directory = flavour.isAbsolute(override) ? override : flavour.join(sessionRuntimeDir(env, platform), "odk", "sessions");
|
|
423
|
+
try {
|
|
424
|
+
return readdirSync(directory).filter((name) => name.endsWith(".json")).map((name) => flavour.join(directory, name));
|
|
425
|
+
} catch {
|
|
426
|
+
return [];
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Whether the session's queue holds an instruction this host just offered.
|
|
432
|
+
*
|
|
433
|
+
* @param ctx The session context the message was sent through.
|
|
434
|
+
*/
|
|
435
|
+
async #resolve(path: string): Promise<string> {
|
|
436
|
+
try {
|
|
437
|
+
return await realpath(path);
|
|
438
|
+
} catch {
|
|
439
|
+
return path;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
#listen(socketPath: string): Promise<Server> {
|
|
444
|
+
return new Promise<Server>((resolve, reject) => {
|
|
445
|
+
const server = createServer((socket) => { this.#serve(socket); });
|
|
446
|
+
server.once("error", reject);
|
|
447
|
+
// The endpoint must never be the reason a Pi process stays alive: a desk
|
|
448
|
+
// reaching this session is not a reason this session cannot be closed.
|
|
449
|
+
server.unref();
|
|
450
|
+
server.listen(socketPath, () => {
|
|
451
|
+
server.off("error", reject);
|
|
452
|
+
resolve(server);
|
|
453
|
+
});
|
|
454
|
+
}).then(async (server) => {
|
|
455
|
+
// Ownership is the gate, so the socket is nobody else's to connect to.
|
|
456
|
+
await chmod(socketPath, 0o600).catch(() => {});
|
|
457
|
+
return server;
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** One connection carries one bounded line in and one frame out, and nothing else. */
|
|
462
|
+
#serve(socket: Socket): void {
|
|
463
|
+
this.#sockets.add(socket);
|
|
464
|
+
socket.on("error", () => {});
|
|
465
|
+
socket.once("close", () => { this.#sockets.delete(socket); });
|
|
466
|
+
socket.setTimeout(this.#timeoutMs, () => socket.destroy());
|
|
467
|
+
const decoder = new StringDecoder("utf8");
|
|
468
|
+
let pending = "";
|
|
469
|
+
let answered = false;
|
|
470
|
+
let processing = false;
|
|
471
|
+
const close = (frame?: string): void => {
|
|
472
|
+
if (answered) return;
|
|
473
|
+
answered = true;
|
|
474
|
+
if (frame === undefined) socket.destroy();
|
|
475
|
+
else socket.end(frame);
|
|
476
|
+
};
|
|
477
|
+
socket.on("data", (chunk) => {
|
|
478
|
+
if (answered || processing) return;
|
|
479
|
+
pending += decoder.write(chunk);
|
|
480
|
+
if (Buffer.byteLength(pending) > SESSION_REQUEST_LIMIT) return close();
|
|
481
|
+
const newline = pending.indexOf("\n");
|
|
482
|
+
if (newline < 0) return;
|
|
483
|
+
// One frame per connection: a second line is not a second request.
|
|
484
|
+
if (pending.slice(newline + 1).trim().length > 0) return close();
|
|
485
|
+
let request: unknown;
|
|
486
|
+
try {
|
|
487
|
+
request = JSON.parse(pending.slice(0, newline));
|
|
488
|
+
} catch {
|
|
489
|
+
return close();
|
|
490
|
+
}
|
|
491
|
+
processing = true;
|
|
492
|
+
this.#respond(request).then((response) => {
|
|
493
|
+
const frame = `${JSON.stringify(response)}\n`;
|
|
494
|
+
close(Buffer.byteLength(frame) > SESSION_RESPONSE_LIMIT ? undefined : frame);
|
|
495
|
+
}, () => close());
|
|
496
|
+
});
|
|
497
|
+
// A connection that ends without a line is closed without a frame, rather than held
|
|
498
|
+
// open until the request timeout.
|
|
499
|
+
socket.once("end", () => close());
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async #respond(request: unknown): Promise<object> {
|
|
503
|
+
const base = { version: SESSION_PROTOCOL_VERSION, requestId: typeof (request as { requestId?: unknown } | null)?.requestId === "string" ? (request as { requestId: string }).requestId : "" };
|
|
504
|
+
try {
|
|
505
|
+
return { ...base, ok: true, ...(await this.#dispatch(request)) };
|
|
506
|
+
} catch (error) {
|
|
507
|
+
return { ...base, ok: false, error: reasonOf(error) };
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async #dispatch(request: unknown): Promise<Record<string, unknown>> {
|
|
512
|
+
if (!isRequest(request)) throw new Error(PROTOCOL_INVALID);
|
|
513
|
+
if (Buffer.byteLength(JSON.stringify(request)) > SESSION_REQUEST_LIMIT) throw new Error(REQUEST_TOO_LARGE);
|
|
514
|
+
const ctx = this.#ctx;
|
|
515
|
+
if (!ctx) throw new Error(TASK_NOT_FOUND);
|
|
516
|
+
if (request.command === "list") {
|
|
517
|
+
await this.#admit(request.project);
|
|
518
|
+
const { prompt, response, ...entry } = this.#task();
|
|
519
|
+
return { tasks: [{ ...entry, goal: prompt }], truncated: false };
|
|
520
|
+
}
|
|
521
|
+
if (request.command === "status") {
|
|
522
|
+
this.#ownTask(request.taskId, request.project);
|
|
523
|
+
return { task: this.#task() };
|
|
524
|
+
}
|
|
525
|
+
if (request.command === "prompt") {
|
|
526
|
+
this.#ownTask(request.taskId, request.project);
|
|
527
|
+
return this.#mutation(request, async () => {
|
|
528
|
+
await this.#deliver(request.prompt, request.streamingBehavior, request.mutationId);
|
|
529
|
+
return { task: this.#task(), submitted: true, accepted: false, delivery: "unknown", retry: "never", reconciliation: "session_execution_only" };
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
if (request.command === "cancel") {
|
|
533
|
+
this.#ownTask(request.taskId, request.project);
|
|
534
|
+
return this.#mutation(request, async () => {
|
|
535
|
+
this.#cancelRequested = true;
|
|
536
|
+
ctx.abort();
|
|
537
|
+
return { task: this.#task(), accepted: true };
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
if (["start", "launch", "end", "history"].includes(request.command as string)) throw new Error(NOT_A_SESSION_HOST);
|
|
541
|
+
throw new Error(UNKNOWN_COMMAND);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** This process is one session, so any other task identity is a task this host does not have. */
|
|
545
|
+
#ownTask(taskId: unknown, project: unknown): void {
|
|
546
|
+
if (taskId !== this.#sessionId || project !== this.#ctx?.cwd) throw new Error(TASK_NOT_FOUND);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async #mutation(request: Record<string, unknown>, run: () => Promise<Record<string, unknown>>): Promise<Record<string, unknown>> {
|
|
550
|
+
const id = request.mutationId;
|
|
551
|
+
if (id === undefined) return run();
|
|
552
|
+
if (typeof id !== "string" || !UUID.test(id)) throw new Error(PROTOCOL_INVALID);
|
|
553
|
+
const payload = JSON.stringify([request.command, request.taskId, request.project, request.prompt, request.streamingBehavior]);
|
|
554
|
+
const previous = this.#mutations.get(id);
|
|
555
|
+
if (previous) {
|
|
556
|
+
if (previous.payload !== payload) throw new Error(MUTATION_CONFLICT);
|
|
557
|
+
return previous.result;
|
|
558
|
+
}
|
|
559
|
+
// Never evict a receipt: an evicted mutation could silently run twice.
|
|
560
|
+
if (this.#mutations.size >= MUTATION_LIMIT) throw new Error("项目或主机任务已满");
|
|
561
|
+
const result = Promise.resolve().then(run).then(result => ({ ...result, mutationId: id }));
|
|
562
|
+
this.#mutations.set(id, { payload, result });
|
|
563
|
+
return result;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* A project scope covers this session's own project and everything inside it, which is the
|
|
568
|
+
* only tree this process can answer for.
|
|
569
|
+
*/
|
|
570
|
+
async #admit(project: unknown): Promise<void> {
|
|
571
|
+
if (project === undefined || project === null) return;
|
|
572
|
+
if (typeof project !== "string" || !isAbsolute(project) || /[\x00-\x1f\x7f]/.test(project)) throw new Error(PROJECT_NOT_ADMITTED);
|
|
573
|
+
let resolved: string;
|
|
574
|
+
try {
|
|
575
|
+
resolved = await realpath(project);
|
|
576
|
+
if (!(await stat(resolved)).isDirectory()) throw new Error(PROJECT_NOT_ADMITTED);
|
|
577
|
+
} catch {
|
|
578
|
+
throw new Error(PROJECT_NOT_ADMITTED);
|
|
579
|
+
}
|
|
580
|
+
if (!within(resolved, this.#realProject)) throw new Error(PROJECT_NOT_ADMITTED);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Refuse an unusable instruction before anything is delivered into the session. */
|
|
584
|
+
async #deliver(prompt: unknown, streamingBehavior: unknown, mutationId: unknown): Promise<void> {
|
|
585
|
+
if (!validText(prompt, SESSION_REQUEST_LIMIT) || prompt.trim().length === 0) throw new Error(PROMPT_INVALID);
|
|
586
|
+
const ctx = this.#ctx;
|
|
587
|
+
if (!ctx) throw new Error(TASK_NOT_FOUND);
|
|
588
|
+
if (streamingBehavior !== undefined && streamingBehavior !== "steer" && streamingBehavior !== "followUp") throw new Error(PROTOCOL_INVALID);
|
|
589
|
+
// Pi 1.0 normal Enter uses steer; its follow-up shortcut uses followUp.
|
|
590
|
+
const behavior: "steer" | "followUp" = streamingBehavior === "followUp" ? "followUp" : "steer";
|
|
591
|
+
if (this.#offered.length >= MUTATION_LIMIT) throw new Error("项目或主机任务已满");
|
|
592
|
+
const offered: { mutationId?: string; state: "unknown"; submitted: true; prompt: string } = { ...(typeof mutationId === "string" ? { mutationId } : {}), state: "unknown", submitted: true, prompt };
|
|
593
|
+
const options = { expandPromptTemplates: true, deliverAs: behavior };
|
|
594
|
+
this.#pi.sendUserMessage(prompt, options);
|
|
595
|
+
this.#offered.push(offered);
|
|
596
|
+
this.#lastDelivery = offered;
|
|
597
|
+
// The public API returns void, with no identity-aware acknowledgment.
|
|
598
|
+
// Matching user text can come from manual input or another queued mutation.
|
|
599
|
+
// Session execution evidence therefore never certifies a mutation's admission.
|
|
600
|
+
if (this.#ctx !== ctx) throw new Error(TASK_NOT_FOUND);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
#stateOutcome(outcome: HostedPiTurnOutcome): void {
|
|
604
|
+
this.#outcome = outcome;
|
|
605
|
+
this.#observed = true;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
#task(): SessionHostTask {
|
|
609
|
+
const ctx = this.#ctx;
|
|
610
|
+
const name = ctx?.sessionManager.getSessionName();
|
|
611
|
+
const turnOutcome = this.#outcome ?? (this.#interrupted ? "interrupted" : undefined);
|
|
612
|
+
return {
|
|
613
|
+
taskId: ctx?.sessionManager.getSessionId() ?? this.#sessionId,
|
|
614
|
+
project: ctx?.cwd ?? "",
|
|
615
|
+
...(name === undefined ? {} : { name }),
|
|
616
|
+
lifecycle: "live",
|
|
617
|
+
state: this.#state,
|
|
618
|
+
activity: this.#state === "running" ? "working" : "idle",
|
|
619
|
+
...(turnOutcome === undefined ? {} : { turnOutcome }),
|
|
620
|
+
prompt: this.#goal,
|
|
621
|
+
response: this.#response,
|
|
622
|
+
...(this.#latestActivity === undefined ? {} : { latestActivity: this.#latestActivity }),
|
|
623
|
+
verification: "not_run",
|
|
624
|
+
...(this.#lastDelivery === undefined ? {} : { lastDelivery: { ...this.#lastDelivery, prompt: boundEventText(this.#lastDelivery.prompt) } }),
|
|
625
|
+
...(this.#offered.length === 0 ? {} : { deliveries: this.#offered.flatMap(delivery => delivery.mutationId === undefined ? [] : [{ mutationId: delivery.mutationId, state: delivery.state, submitted: delivery.submitted }]) }),
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function reasonOf(error: unknown): string {
|
|
631
|
+
return error instanceof Error && !("code" in error) ? error.message : "任务请求失败";
|
|
632
|
+
}
|