@oh-my-pi/pi-coding-agent 16.4.8 → 16.5.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/CHANGELOG.md +63 -0
- package/dist/cli.js +3502 -3444
- package/dist/types/cli/args.d.ts +5 -0
- package/dist/types/cli/gallery-fixtures/shell.d.ts +1 -1
- package/dist/types/commands/launch.d.ts +15 -0
- package/dist/types/config/model-resolver.d.ts +4 -3
- package/dist/types/config/model-roles.d.ts +8 -0
- package/dist/types/config/settings-schema.d.ts +46 -14
- package/dist/types/extensibility/extensions/types.d.ts +1 -1
- package/dist/types/launch/broker.d.ts +2 -0
- package/dist/types/launch/client.d.ts +22 -0
- package/dist/types/launch/paths.d.ts +4 -0
- package/dist/types/launch/presence.d.ts +8 -0
- package/dist/types/launch/protocol.d.ts +170 -0
- package/dist/types/launch/terminal-output.d.ts +7 -0
- package/dist/types/modes/components/agent-hub.d.ts +1 -1
- package/dist/types/modes/components/index.d.ts +1 -0
- package/dist/types/modes/components/model-browser.d.ts +17 -2
- package/dist/types/modes/components/model-hub.d.ts +0 -10
- package/dist/types/modes/components/model-picker.d.ts +44 -0
- package/dist/types/modes/components/status-line/types.d.ts +3 -0
- package/dist/types/modes/print-mode.d.ts +14 -8
- package/dist/types/modes/print-mode.test.d.ts +1 -0
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/sdk.d.ts +5 -1
- package/dist/types/session/agent-session.d.ts +42 -0
- package/dist/types/session/session-context.d.ts +9 -0
- package/dist/types/session/session-entries.d.ts +6 -0
- package/dist/types/thinking.d.ts +2 -2
- package/dist/types/tiny/models.d.ts +1 -1
- package/dist/types/tools/browser/launch.d.ts +1 -0
- package/dist/types/tools/browser/run-cancellation.d.ts +28 -2
- package/dist/types/tools/browser/tab-protocol.d.ts +6 -0
- package/dist/types/tools/browser/tab-worker.d.ts +6 -0
- package/dist/types/tools/builtin-names.d.ts +1 -1
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/launch.d.ts +121 -0
- package/dist/types/tools/render-utils.d.ts +2 -0
- package/dist/types/tools/terminal-output.d.ts +5 -0
- package/dist/types/vibe/runtime.d.ts +2 -2
- package/dist/types/web/search/types.d.ts +0 -8
- package/package.json +20 -20
- package/src/cli/args.ts +11 -0
- package/src/cli/flag-tables.ts +9 -0
- package/src/cli/gallery-fixtures/shell.ts +82 -1
- package/src/cli.ts +10 -0
- package/src/commands/launch.ts +17 -0
- package/src/config/model-resolver.ts +110 -31
- package/src/config/model-roles.ts +14 -0
- package/src/config/settings-schema.ts +71 -7
- package/src/edit/renderer.ts +13 -10
- package/src/eval/__tests__/agent-bridge.test.ts +2 -2
- package/src/eval/__tests__/completion-bridge.test.ts +1 -1
- package/src/eval/completion-bridge.ts +4 -4
- package/src/export/ttsr.ts +0 -3
- package/src/extensibility/extensions/model-api.ts +1 -1
- package/src/extensibility/extensions/types.ts +1 -1
- package/src/internal-urls/docs-index.ts +4 -4
- package/src/launch/broker.ts +1017 -0
- package/src/launch/client.ts +344 -0
- package/src/launch/paths.ts +17 -0
- package/src/launch/presence.ts +82 -0
- package/src/launch/protocol.ts +386 -0
- package/src/launch/terminal-output.ts +46 -0
- package/src/main.ts +50 -1
- package/src/modes/acp/acp-agent.ts +8 -1
- package/src/modes/components/agent-hub.ts +101 -31
- package/src/modes/components/compaction-summary-message.ts +8 -2
- package/src/modes/components/index.ts +1 -0
- package/src/modes/components/model-browser.ts +111 -21
- package/src/modes/components/model-hub.ts +23 -116
- package/src/modes/components/model-picker.ts +233 -0
- package/src/modes/components/snapcompact-shape-preview-doc.md +7 -11
- package/src/modes/components/status-line/component.test.ts +41 -2
- package/src/modes/components/status-line/component.ts +4 -0
- package/src/modes/components/status-line/segments.ts +6 -0
- package/src/modes/components/status-line/types.ts +3 -0
- package/src/modes/controllers/command-controller.ts +9 -1
- package/src/modes/controllers/event-controller.ts +31 -32
- package/src/modes/controllers/selector-controller.ts +96 -22
- package/src/modes/controllers/tan-command-controller.ts +40 -1
- package/src/modes/interactive-mode.ts +7 -3
- package/src/modes/print-mode.test.ts +71 -0
- package/src/modes/print-mode.ts +51 -2
- package/src/modes/theme/theme.ts +9 -0
- package/src/modes/utils/ui-helpers.ts +25 -4
- package/src/prompts/agents/designer.md +1 -1
- package/src/prompts/agents/librarian.md +1 -1
- package/src/prompts/agents/reviewer.md +1 -1
- package/src/prompts/agents/scout.md +1 -1
- package/src/prompts/system/plan-yolo-handoff.md +5 -0
- package/src/prompts/system/prewalk-checklist.md +7 -0
- package/src/prompts/system/prewalk-continue.md +1 -0
- package/src/prompts/system/prewalk-plan.md +13 -0
- package/src/prompts/system/system-prompt.md +9 -7
- package/src/prompts/system/tan-context-switch.md +17 -0
- package/src/prompts/tools/bash.md +6 -4
- package/src/prompts/tools/browser.md +4 -4
- package/src/prompts/tools/launch.md +25 -0
- package/src/sdk.ts +8 -2
- package/src/session/agent-session.ts +550 -87
- package/src/session/session-context.test.ts +10 -5
- package/src/session/session-context.ts +25 -8
- package/src/session/session-entries.ts +6 -0
- package/src/slash-commands/builtin-registry.ts +25 -0
- package/src/task/agents.ts +2 -2
- package/src/thinking.ts +10 -3
- package/src/tiny/models.ts +7 -7
- package/src/tools/bash-interactive.ts +5 -8
- package/src/tools/bash.ts +38 -24
- package/src/tools/browser/cmux/cmux-tab.ts +17 -2
- package/src/tools/browser/launch.ts +8 -4
- package/src/tools/browser/run-cancellation.ts +66 -6
- package/src/tools/browser/tab-protocol.ts +6 -0
- package/src/tools/browser/tab-supervisor.ts +17 -1
- package/src/tools/browser/tab-worker.ts +140 -21
- package/src/tools/builtin-names.ts +1 -0
- package/src/tools/eval-render.ts +16 -14
- package/src/tools/index.ts +5 -0
- package/src/tools/inspect-image.ts +2 -2
- package/src/tools/launch.ts +643 -0
- package/src/tools/render-utils.ts +3 -0
- package/src/tools/renderers.ts +2 -0
- package/src/tools/terminal-output.ts +141 -0
- package/src/tts/speech-enhancer.ts +2 -2
- package/src/utils/image-vision-fallback.ts +3 -3
- package/src/vibe/runtime.ts +2 -2
- package/src/web/search/provider.ts +0 -10
- package/src/web/search/providers/public.ts +2 -4
- package/src/web/search/types.ts +0 -10
- package/dist/types/web/search/providers/bing.d.ts +0 -14
- package/dist/types/web/search/providers/yahoo.d.ts +0 -14
- package/src/web/search/providers/bing.ts +0 -197
- package/src/web/search/providers/yahoo.ts +0 -179
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as net from "node:net";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { isEexist, isEnoent, postmortem } from "@oh-my-pi/pi-utils";
|
|
6
|
+
import { resolveWorkerSpawnCmd, workerEnvFromParent } from "../subprocess/worker-client";
|
|
7
|
+
import { daemonBrokerEndpoint, daemonRuntimeDir } from "./paths";
|
|
8
|
+
import {
|
|
9
|
+
DAEMON_BROKER_WORKER_ARG,
|
|
10
|
+
DAEMON_IDLE_GRACE_ENV,
|
|
11
|
+
DAEMON_PROJECT_DIR_ENV,
|
|
12
|
+
DAEMON_RUNTIME_DIR_ENV,
|
|
13
|
+
type DaemonOperation,
|
|
14
|
+
type DaemonRpcResult,
|
|
15
|
+
type DaemonWireResponse,
|
|
16
|
+
parseDaemonRpcResult,
|
|
17
|
+
parseDaemonWireResponse,
|
|
18
|
+
} from "./protocol";
|
|
19
|
+
|
|
20
|
+
const CONNECT_TIMEOUT_MS = 10_000;
|
|
21
|
+
const CONNECT_RETRY_MS = 50;
|
|
22
|
+
const TOKEN_FILE = "broker.token";
|
|
23
|
+
|
|
24
|
+
interface PendingRequest {
|
|
25
|
+
operation: DaemonOperation;
|
|
26
|
+
resolve: (result: DaemonRpcResult) => void;
|
|
27
|
+
reject: (error: Error) => void;
|
|
28
|
+
timer: NodeJS.Timeout;
|
|
29
|
+
removeAbort?: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Broker location and lifecycle overrides used by smoke tests and isolated consumers. */
|
|
33
|
+
export interface DaemonBrokerClientOptions {
|
|
34
|
+
/** Runtime directory override; defaults to the project-scoped config path. */
|
|
35
|
+
runtimeDir?: string;
|
|
36
|
+
/** Last-client shutdown grace override in milliseconds. */
|
|
37
|
+
idleGraceMs?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Persistent per-process connection to one project's daemon broker. */
|
|
41
|
+
export interface DaemonBrokerClient {
|
|
42
|
+
readonly projectDir: string;
|
|
43
|
+
request(operation: DaemonOperation, signal?: AbortSignal): Promise<DaemonRpcResult>;
|
|
44
|
+
close(): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function canonicalProjectDir(projectDir: string): Promise<string> {
|
|
48
|
+
const resolved = path.resolve(projectDir);
|
|
49
|
+
try {
|
|
50
|
+
return await fs.realpath(resolved);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (isEnoent(error)) return resolved;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readOrCreateToken(runtimeDir: string): Promise<string> {
|
|
58
|
+
await fs.mkdir(runtimeDir, { recursive: true, mode: 0o700 });
|
|
59
|
+
const tokenPath = path.join(runtimeDir, TOKEN_FILE);
|
|
60
|
+
const tokenFile = Bun.file(tokenPath);
|
|
61
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
62
|
+
try {
|
|
63
|
+
const token = (await tokenFile.text()).trim();
|
|
64
|
+
if (token.length > 0) return token;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (!isEnoent(error)) throw error;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const handle = await fs.open(tokenPath, "wx", 0o600);
|
|
71
|
+
try {
|
|
72
|
+
const token = crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "");
|
|
73
|
+
await handle.writeFile(token, "utf8");
|
|
74
|
+
return token;
|
|
75
|
+
} finally {
|
|
76
|
+
await handle.close();
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (!isEexist(error)) throw error;
|
|
80
|
+
}
|
|
81
|
+
await Bun.sleep(10);
|
|
82
|
+
}
|
|
83
|
+
throw new Error(`Timed out initializing daemon broker token in ${runtimeDir}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function requestTimeoutMs(operation: DaemonOperation): number {
|
|
87
|
+
switch (operation.op) {
|
|
88
|
+
case "start":
|
|
89
|
+
return (operation.spec.ready?.timeoutMs ?? CONNECT_TIMEOUT_MS) + 5_000;
|
|
90
|
+
case "wait":
|
|
91
|
+
case "logs":
|
|
92
|
+
case "stop":
|
|
93
|
+
return operation.timeoutMs + 5_000;
|
|
94
|
+
default:
|
|
95
|
+
return 30_000;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function openSocket(endpoint: string, timeoutMs: number): Promise<net.Socket> {
|
|
100
|
+
const { promise, resolve, reject } = Promise.withResolvers<net.Socket>();
|
|
101
|
+
const socket = net.createConnection({ path: endpoint });
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
socket.destroy();
|
|
104
|
+
reject(new Error(`Timed out connecting to daemon broker at ${endpoint}`));
|
|
105
|
+
}, timeoutMs);
|
|
106
|
+
const cleanup = (): void => {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
socket.off("connect", onConnect);
|
|
109
|
+
socket.off("error", onError);
|
|
110
|
+
};
|
|
111
|
+
const onConnect = (): void => {
|
|
112
|
+
cleanup();
|
|
113
|
+
resolve(socket);
|
|
114
|
+
};
|
|
115
|
+
const onError = (error: Error): void => {
|
|
116
|
+
cleanup();
|
|
117
|
+
socket.destroy();
|
|
118
|
+
reject(error);
|
|
119
|
+
};
|
|
120
|
+
socket.once("connect", onConnect);
|
|
121
|
+
socket.once("error", onError);
|
|
122
|
+
return promise;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
class SocketDaemonClient implements DaemonBrokerClient {
|
|
126
|
+
readonly projectDir: string;
|
|
127
|
+
readonly #runtimeDir: string;
|
|
128
|
+
readonly #endpoint: string;
|
|
129
|
+
readonly #token: string;
|
|
130
|
+
readonly #idleGraceMs: number | undefined;
|
|
131
|
+
readonly #pending = new Map<string, PendingRequest>();
|
|
132
|
+
#socket: net.Socket | undefined;
|
|
133
|
+
#connectPromise: Promise<void> | undefined;
|
|
134
|
+
#buffer = "";
|
|
135
|
+
#closed = false;
|
|
136
|
+
|
|
137
|
+
constructor(projectDir: string, runtimeDir: string, token: string, options: DaemonBrokerClientOptions) {
|
|
138
|
+
this.projectDir = projectDir;
|
|
139
|
+
this.#runtimeDir = runtimeDir;
|
|
140
|
+
this.#endpoint = daemonBrokerEndpoint(projectDir, runtimeDir);
|
|
141
|
+
this.#token = token;
|
|
142
|
+
this.#idleGraceMs = options.idleGraceMs;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async request(operation: DaemonOperation, signal?: AbortSignal): Promise<DaemonRpcResult> {
|
|
146
|
+
if (this.#closed) throw new Error("Daemon broker client is closed");
|
|
147
|
+
if (signal?.aborted) throw new Error("Daemon broker request aborted");
|
|
148
|
+
await this.#connect();
|
|
149
|
+
const socket = this.#socket;
|
|
150
|
+
if (!socket || socket.destroyed) throw new Error("Daemon broker socket is unavailable");
|
|
151
|
+
|
|
152
|
+
const id = crypto.randomUUID();
|
|
153
|
+
const { promise, resolve, reject } = Promise.withResolvers<DaemonRpcResult>();
|
|
154
|
+
const timer = setTimeout(() => {
|
|
155
|
+
const pending = this.#pending.get(id);
|
|
156
|
+
if (!pending) return;
|
|
157
|
+
this.#pending.delete(id);
|
|
158
|
+
pending.removeAbort?.();
|
|
159
|
+
reject(new Error(`Daemon ${operation.op} request timed out`));
|
|
160
|
+
}, requestTimeoutMs(operation));
|
|
161
|
+
const pending: PendingRequest = { operation, resolve, reject, timer };
|
|
162
|
+
if (signal) {
|
|
163
|
+
const abort = (): void => {
|
|
164
|
+
if (!this.#pending.delete(id)) return;
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
reject(new Error("Daemon broker request aborted"));
|
|
167
|
+
};
|
|
168
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
169
|
+
pending.removeAbort = () => signal.removeEventListener("abort", abort);
|
|
170
|
+
}
|
|
171
|
+
this.#pending.set(id, pending);
|
|
172
|
+
socket.write(`${JSON.stringify({ id, token: this.#token, operation })}\n`);
|
|
173
|
+
return promise;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
close(): void {
|
|
177
|
+
if (this.#closed) return;
|
|
178
|
+
this.#closed = true;
|
|
179
|
+
this.#socket?.destroy();
|
|
180
|
+
this.#socket = undefined;
|
|
181
|
+
this.#rejectPending(new Error("Daemon broker client closed"));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async #connect(): Promise<void> {
|
|
185
|
+
if (this.#socket && !this.#socket.destroyed) return;
|
|
186
|
+
if (this.#connectPromise) return this.#connectPromise;
|
|
187
|
+
this.#connectPromise = this.#connectOnce();
|
|
188
|
+
try {
|
|
189
|
+
await this.#connectPromise;
|
|
190
|
+
} finally {
|
|
191
|
+
this.#connectPromise = undefined;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async #connectOnce(): Promise<void> {
|
|
196
|
+
try {
|
|
197
|
+
this.#bindSocket(await openSocket(this.#endpoint, 250));
|
|
198
|
+
return;
|
|
199
|
+
} catch {
|
|
200
|
+
// No live broker. Multiple clients may race to spawn; the broker's PID
|
|
201
|
+
// lease selects one winner before any candidate touches the socket.
|
|
202
|
+
}
|
|
203
|
+
this.#spawnBroker();
|
|
204
|
+
const deadline = Date.now() + CONNECT_TIMEOUT_MS;
|
|
205
|
+
let lastError: Error | undefined;
|
|
206
|
+
while (Date.now() < deadline) {
|
|
207
|
+
try {
|
|
208
|
+
this.#bindSocket(await openSocket(this.#endpoint, 250));
|
|
209
|
+
return;
|
|
210
|
+
} catch (error) {
|
|
211
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
212
|
+
await Bun.sleep(CONNECT_RETRY_MS);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
throw new Error(`Failed to start daemon broker: ${lastError?.message ?? "socket unavailable"}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#spawnBroker(): void {
|
|
219
|
+
const spawn = resolveWorkerSpawnCmd(DAEMON_BROKER_WORKER_ARG);
|
|
220
|
+
const overlay: Record<string, string> = {
|
|
221
|
+
[DAEMON_PROJECT_DIR_ENV]: this.projectDir,
|
|
222
|
+
[DAEMON_RUNTIME_DIR_ENV]: this.#runtimeDir,
|
|
223
|
+
};
|
|
224
|
+
if (this.#idleGraceMs !== undefined) overlay[DAEMON_IDLE_GRACE_ENV] = String(this.#idleGraceMs);
|
|
225
|
+
const child = Bun.spawn(spawn.cmd, {
|
|
226
|
+
cwd: spawn.cwd,
|
|
227
|
+
env: workerEnvFromParent(overlay),
|
|
228
|
+
stdin: "ignore",
|
|
229
|
+
stdout: "ignore",
|
|
230
|
+
stderr: "ignore",
|
|
231
|
+
detached: true,
|
|
232
|
+
});
|
|
233
|
+
child.unref();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#bindSocket(socket: net.Socket): void {
|
|
237
|
+
this.#socket = socket;
|
|
238
|
+
this.#buffer = "";
|
|
239
|
+
socket.setEncoding("utf8");
|
|
240
|
+
socket.on("data", chunk => this.#onData(chunk));
|
|
241
|
+
socket.on("error", () => {
|
|
242
|
+
// The close handler rejects pending requests with one stable error.
|
|
243
|
+
});
|
|
244
|
+
socket.on("close", () => {
|
|
245
|
+
if (this.#socket === socket) this.#socket = undefined;
|
|
246
|
+
this.#rejectPending(new Error("Daemon broker connection closed"));
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#onData(chunk: string | Buffer): void {
|
|
251
|
+
this.#buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
252
|
+
for (;;) {
|
|
253
|
+
const newline = this.#buffer.indexOf("\n");
|
|
254
|
+
if (newline < 0) return;
|
|
255
|
+
const line = this.#buffer.slice(0, newline);
|
|
256
|
+
this.#buffer = this.#buffer.slice(newline + 1);
|
|
257
|
+
if (line.length === 0) continue;
|
|
258
|
+
let response: DaemonWireResponse;
|
|
259
|
+
try {
|
|
260
|
+
const decoded: unknown = JSON.parse(line);
|
|
261
|
+
response = parseDaemonWireResponse(decoded);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
this.#rejectPending(error instanceof Error ? error : new Error(String(error)));
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const pending = this.#pending.get(response.id);
|
|
267
|
+
if (!pending) continue;
|
|
268
|
+
this.#pending.delete(response.id);
|
|
269
|
+
clearTimeout(pending.timer);
|
|
270
|
+
pending.removeAbort?.();
|
|
271
|
+
if (!response.ok) {
|
|
272
|
+
pending.reject(new Error(response.error));
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
pending.resolve(parseDaemonRpcResult(pending.operation, response.result));
|
|
277
|
+
} catch (error) {
|
|
278
|
+
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
#rejectPending(error: Error): void {
|
|
284
|
+
for (const pending of this.#pending.values()) {
|
|
285
|
+
clearTimeout(pending.timer);
|
|
286
|
+
pending.removeAbort?.();
|
|
287
|
+
pending.reject(error);
|
|
288
|
+
}
|
|
289
|
+
this.#pending.clear();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const sharedClients = new Map<string, Promise<DaemonBrokerClient>>();
|
|
294
|
+
let cancelExitCleanup: (() => void) | undefined;
|
|
295
|
+
|
|
296
|
+
/** Create an independent socket connection to one project's shared daemon broker. */
|
|
297
|
+
export async function createDaemonBrokerClient(
|
|
298
|
+
projectDir: string,
|
|
299
|
+
options: DaemonBrokerClientOptions = {},
|
|
300
|
+
): Promise<DaemonBrokerClient> {
|
|
301
|
+
const canonical = await canonicalProjectDir(projectDir);
|
|
302
|
+
const runtimeDir = options.runtimeDir ?? daemonRuntimeDir(canonical);
|
|
303
|
+
const token = await readOrCreateToken(runtimeDir);
|
|
304
|
+
return new SocketDaemonClient(canonical, runtimeDir, token, options);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Get the process-shared daemon broker client for one canonical project directory. */
|
|
308
|
+
export async function daemonClientForProject(projectDir: string): Promise<DaemonBrokerClient> {
|
|
309
|
+
const canonical = await canonicalProjectDir(projectDir);
|
|
310
|
+
let pending = sharedClients.get(canonical);
|
|
311
|
+
if (!pending) {
|
|
312
|
+
pending = createDaemonBrokerClient(canonical);
|
|
313
|
+
sharedClients.set(canonical, pending);
|
|
314
|
+
if (!cancelExitCleanup) {
|
|
315
|
+
cancelExitCleanup = postmortem.register("daemon-broker-clients", () => closeDaemonClients());
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return pending;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Close every project broker connection held by this omp process. */
|
|
322
|
+
export async function closeDaemonClients(): Promise<void> {
|
|
323
|
+
const pending = [...sharedClients.values()];
|
|
324
|
+
sharedClients.clear();
|
|
325
|
+
for (const client of await Promise.all(pending)) client.close();
|
|
326
|
+
cancelExitCleanup?.();
|
|
327
|
+
cancelExitCleanup = undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Exercise worker-host broker startup and authenticated RPC for distribution smoke tests. */
|
|
331
|
+
export async function smokeTestDaemonBroker(): Promise<void> {
|
|
332
|
+
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-project-"));
|
|
333
|
+
const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-daemon-smoke-run-"));
|
|
334
|
+
const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 });
|
|
335
|
+
try {
|
|
336
|
+
const ping = await client.request({ op: "ping" });
|
|
337
|
+
if (ping.op !== "ping" || ping.projectDir !== client.projectDir) throw new Error("daemon broker ping mismatch");
|
|
338
|
+
await client.request({ op: "shutdown" });
|
|
339
|
+
} finally {
|
|
340
|
+
client.close();
|
|
341
|
+
await fs.rm(projectDir, { recursive: true, force: true });
|
|
342
|
+
await fs.rm(runtimeDir, { recursive: true, force: true });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { getConfigRootDir } from "@oh-my-pi/pi-utils";
|
|
3
|
+
|
|
4
|
+
/** Resolve the private runtime directory shared by omp processes in one project directory. */
|
|
5
|
+
export function daemonRuntimeDir(projectDir: string, configRoot: string = getConfigRootDir()): string {
|
|
6
|
+
const key = Bun.hash.wyhash(path.resolve(projectDir)).toString(16).padStart(16, "0");
|
|
7
|
+
return path.join(configRoot, "run", "daemons", key);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Resolve the Unix socket or Windows named pipe used by one project broker. */
|
|
11
|
+
export function daemonBrokerEndpoint(projectDir: string, runtimeDir: string): string {
|
|
12
|
+
if (process.platform === "win32") {
|
|
13
|
+
const key = Bun.hash.wyhash(path.resolve(projectDir)).toString(16).padStart(16, "0");
|
|
14
|
+
return `\\\\.\\pipe\\omp-daemon-${key}`;
|
|
15
|
+
}
|
|
16
|
+
return path.join(runtimeDir, "broker.sock");
|
|
17
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { isEnoent, postmortem } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import { daemonRuntimeDir } from "./paths";
|
|
5
|
+
|
|
6
|
+
const CLIENTS_DIR = "clients";
|
|
7
|
+
|
|
8
|
+
/** Handle keeping one omp process registered in a project daemon scope. */
|
|
9
|
+
export interface DaemonProjectPresence {
|
|
10
|
+
close(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function canonicalProjectDir(projectDir: string): Promise<string> {
|
|
14
|
+
const resolved = path.resolve(projectDir);
|
|
15
|
+
try {
|
|
16
|
+
return await fs.realpath(resolved);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (isEnoent(error)) return resolved;
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Register this omp process so project daemons survive while it remains alive. */
|
|
24
|
+
export async function registerDaemonProjectPresence(
|
|
25
|
+
projectDir: string,
|
|
26
|
+
runtimeOverride?: string,
|
|
27
|
+
): Promise<DaemonProjectPresence> {
|
|
28
|
+
const canonical = await canonicalProjectDir(projectDir);
|
|
29
|
+
const runtimeDir = runtimeOverride ?? daemonRuntimeDir(canonical);
|
|
30
|
+
const clientsDir = path.join(runtimeDir, CLIENTS_DIR);
|
|
31
|
+
await fs.mkdir(clientsDir, { recursive: true, mode: 0o700 });
|
|
32
|
+
const id = `${process.pid}-${crypto.randomUUID()}`;
|
|
33
|
+
const presencePath = path.join(clientsDir, `${id}.json`);
|
|
34
|
+
await Bun.write(presencePath, JSON.stringify({ pid: process.pid, id, projectDir: canonical }));
|
|
35
|
+
await fs.chmod(presencePath, 0o600);
|
|
36
|
+
let closed = false;
|
|
37
|
+
const close = async (): Promise<void> => {
|
|
38
|
+
if (closed) return;
|
|
39
|
+
closed = true;
|
|
40
|
+
cancelCleanup();
|
|
41
|
+
await fs.rm(presencePath, { force: true });
|
|
42
|
+
};
|
|
43
|
+
const cancelCleanup = postmortem.register(`daemon-presence:${id}`, () => close());
|
|
44
|
+
return { close };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Return whether a registered omp process in this runtime directory is still alive. */
|
|
48
|
+
export async function hasLiveDaemonProjectPresence(runtimeDir: string): Promise<boolean> {
|
|
49
|
+
const clientsDir = path.join(runtimeDir, CLIENTS_DIR);
|
|
50
|
+
let entries: string[];
|
|
51
|
+
try {
|
|
52
|
+
entries = await fs.readdir(clientsDir);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (isEnoent(error)) return false;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
let live = false;
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
const presencePath = path.join(clientsDir, entry);
|
|
60
|
+
try {
|
|
61
|
+
const decoded: unknown = await Bun.file(presencePath).json();
|
|
62
|
+
if (
|
|
63
|
+
typeof decoded !== "object" ||
|
|
64
|
+
decoded === null ||
|
|
65
|
+
!("pid" in decoded) ||
|
|
66
|
+
typeof decoded.pid !== "number"
|
|
67
|
+
) {
|
|
68
|
+
await fs.rm(presencePath, { force: true });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
process.kill(decoded.pid, 0);
|
|
73
|
+
live = true;
|
|
74
|
+
} catch {
|
|
75
|
+
await fs.rm(presencePath, { force: true });
|
|
76
|
+
}
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (!isEnoent(error)) await fs.rm(presencePath, { force: true });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return live;
|
|
82
|
+
}
|