@cjhyy/code-shell-server 0.8.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 +77 -0
- package/dist/attachment-service.d.ts +80 -0
- package/dist/attachment-service.js +587 -0
- package/dist/bin/code-shell-serve.d.ts +2 -0
- package/dist/bin/code-shell-serve.js +7 -0
- package/dist/client-message-id.d.ts +1 -0
- package/dist/client-message-id.js +8 -0
- package/dist/image-byte-probe.d.ts +17 -0
- package/dist/image-byte-probe.js +330 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/index.mobile-remote.d.ts +21 -0
- package/dist/index.mobile-remote.js +21 -0
- package/dist/index.serve.d.ts +3 -0
- package/dist/index.serve.js +3 -0
- package/dist/index.storage.d.ts +10 -0
- package/dist/index.storage.js +10 -0
- package/dist/index.worker.d.ts +2 -0
- package/dist/index.worker.js +2 -0
- package/dist/mobile-remote/access-passcode.d.ts +80 -0
- package/dist/mobile-remote/access-passcode.js +354 -0
- package/dist/mobile-remote/cloudflared-binary.d.ts +76 -0
- package/dist/mobile-remote/cloudflared-binary.js +242 -0
- package/dist/mobile-remote/codex-parse.d.ts +34 -0
- package/dist/mobile-remote/codex-parse.js +131 -0
- package/dist/mobile-remote/codex-room-agent.d.ts +94 -0
- package/dist/mobile-remote/codex-room-agent.js +213 -0
- package/dist/mobile-remote/mobile-attachments.d.ts +21 -0
- package/dist/mobile-remote/mobile-attachments.js +119 -0
- package/dist/mobile-remote/mobile-chat-turn.d.ts +32 -0
- package/dist/mobile-remote/mobile-chat-turn.js +67 -0
- package/dist/mobile-remote/mobile-history.d.ts +22 -0
- package/dist/mobile-remote/mobile-history.js +27 -0
- package/dist/mobile-remote/mobile-run-dispatch.d.ts +17 -0
- package/dist/mobile-remote/mobile-run-dispatch.js +56 -0
- package/dist/mobile-remote/mobile-static.d.ts +43 -0
- package/dist/mobile-remote/mobile-static.js +154 -0
- package/dist/mobile-remote/mobile-upload-service.d.ts +57 -0
- package/dist/mobile-remote/mobile-upload-service.js +370 -0
- package/dist/mobile-remote/pairing.d.ts +8 -0
- package/dist/mobile-remote/pairing.js +23 -0
- package/dist/mobile-remote/path-bins.d.ts +7 -0
- package/dist/mobile-remote/path-bins.js +16 -0
- package/dist/mobile-remote/pending-approvals.d.ts +14 -0
- package/dist/mobile-remote/pending-approvals.js +54 -0
- package/dist/mobile-remote/remote-host-manager.d.ts +117 -0
- package/dist/mobile-remote/remote-host-manager.js +345 -0
- package/dist/mobile-remote/resident-agent.d.ts +98 -0
- package/dist/mobile-remote/resident-agent.js +216 -0
- package/dist/mobile-remote/room-manager.d.ts +286 -0
- package/dist/mobile-remote/room-manager.js +765 -0
- package/dist/mobile-remote/trusted-device-store.d.ts +19 -0
- package/dist/mobile-remote/trusted-device-store.js +118 -0
- package/dist/mobile-remote/tunnel-manager.d.ts +105 -0
- package/dist/mobile-remote/tunnel-manager.js +375 -0
- package/dist/mobile-remote/types.d.ts +1 -0
- package/dist/mobile-remote/types.js +1 -0
- package/dist/mobile-remote/viewer-identity.d.ts +5 -0
- package/dist/mobile-remote/viewer-identity.js +5 -0
- package/dist/serve/cli.d.ts +15 -0
- package/dist/serve/cli.js +115 -0
- package/dist/serve/headless-server.d.ts +40 -0
- package/dist/serve/headless-server.js +276 -0
- package/dist/sessions-service.d.ts +73 -0
- package/dist/sessions-service.js +303 -0
- package/dist/static-files.d.ts +1 -0
- package/dist/static-files.js +25 -0
- package/dist/worker-bridge-core.d.ts +154 -0
- package/dist/worker-bridge-core.js +315 -0
- package/package.json +63 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List session files persisted by the agent worker.
|
|
3
|
+
* Uses core's canonical session root so CODE_SHELL_HOME matches the worker.
|
|
4
|
+
*/
|
|
5
|
+
import { SessionManager, sessionsRoot } from "@cjhyy/code-shell-core";
|
|
6
|
+
import * as fs from "node:fs/promises";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
const SAFE_ID = /^[A-Za-z0-9_.-]+$/;
|
|
9
|
+
const QUICK_CHAT_SESSION_PREFIX = "qchat-";
|
|
10
|
+
function isQuickChatSessionId(id) {
|
|
11
|
+
return id.startsWith(QUICK_CHAT_SESSION_PREFIX);
|
|
12
|
+
}
|
|
13
|
+
export async function listSessions(baseDir = sessionsRoot()) {
|
|
14
|
+
try {
|
|
15
|
+
const entries = await fs.readdir(baseDir, { withFileTypes: true });
|
|
16
|
+
const summaries = [];
|
|
17
|
+
for (const e of entries) {
|
|
18
|
+
if (!e.isFile())
|
|
19
|
+
continue;
|
|
20
|
+
if (!e.name.endsWith(".jsonl") && !e.name.endsWith(".json"))
|
|
21
|
+
continue;
|
|
22
|
+
const id = e.name.replace(/\.jsonl?$/, "");
|
|
23
|
+
if (isQuickChatSessionId(id))
|
|
24
|
+
continue;
|
|
25
|
+
const full = path.join(baseDir, e.name);
|
|
26
|
+
try {
|
|
27
|
+
const st = await fs.stat(full);
|
|
28
|
+
summaries.push({
|
|
29
|
+
id,
|
|
30
|
+
file: full,
|
|
31
|
+
size: st.size,
|
|
32
|
+
createdAt: st.birthtimeMs || st.mtimeMs,
|
|
33
|
+
updatedAt: st.mtimeMs,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Skip files we cannot stat (race with delete, permissions).
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
summaries.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
41
|
+
return summaries;
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
if (e.code === "ENOENT")
|
|
45
|
+
return [];
|
|
46
|
+
throw e;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Remove a session's on-disk data. Modern sessions are a directory
|
|
51
|
+
* `<sessionsDir>/<id>/` (transcript.jsonl + state.json — see core
|
|
52
|
+
* session-manager.ts:93,141). Pre-directory sessions were flat
|
|
53
|
+
* `<id>.jsonl`/`<id>.json` files; we remove those too for back-compat.
|
|
54
|
+
* `baseDir` is overridable for tests. Unsafe ids (slashes / "." / "..")
|
|
55
|
+
* are rejected without any filesystem access.
|
|
56
|
+
*/
|
|
57
|
+
export async function deleteSessionDir(id, baseDir = sessionsRoot()) {
|
|
58
|
+
if (!SAFE_ID.test(id) || id === "." || id === "..")
|
|
59
|
+
return;
|
|
60
|
+
// Directory form (current).
|
|
61
|
+
await fs.rm(path.join(baseDir, id), { recursive: true, force: true });
|
|
62
|
+
// Legacy flat files.
|
|
63
|
+
for (const ext of [".jsonl", ".json"]) {
|
|
64
|
+
try {
|
|
65
|
+
await fs.unlink(path.join(baseDir, `${id}${ext}`));
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
if (e.code !== "ENOENT")
|
|
69
|
+
throw e;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** @deprecated retained for the existing IPC handler; delegates to deleteSessionDir. */
|
|
74
|
+
export async function deleteSession(id) {
|
|
75
|
+
await deleteSessionDir(id);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Reap quick-chat data left by an unclean prior desktop exit. This must run
|
|
79
|
+
* before a new renderer/worker can claim a qchat id; normal live cleanup is
|
|
80
|
+
* coordinated by QuickChatOwnershipRegistry instead.
|
|
81
|
+
*/
|
|
82
|
+
export async function cleanupStaleQuickChatSessions(baseDir = sessionsRoot()) {
|
|
83
|
+
let entries;
|
|
84
|
+
try {
|
|
85
|
+
entries = await fs.readdir(baseDir, { withFileTypes: true });
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
if (e.code === "ENOENT")
|
|
89
|
+
return [];
|
|
90
|
+
throw e;
|
|
91
|
+
}
|
|
92
|
+
const ids = new Set();
|
|
93
|
+
const stagingDirs = [];
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (entry.isDirectory() && SAFE_ID.test(entry.name) && isQuickChatSessionId(entry.name)) {
|
|
96
|
+
ids.add(entry.name);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (entry.isDirectory() && entry.name.startsWith(".pending-fork-")) {
|
|
100
|
+
try {
|
|
101
|
+
const state = JSON.parse(await fs.readFile(path.join(baseDir, entry.name, "state.json"), "utf8"));
|
|
102
|
+
if (state.ephemeral === true &&
|
|
103
|
+
typeof state.sessionId === "string" &&
|
|
104
|
+
isQuickChatSessionId(state.sessionId)) {
|
|
105
|
+
stagingDirs.push(entry.name);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Missing, malformed, or unreadable state cannot prove ephemeral
|
|
110
|
+
// ownership, so fail closed and leave the staging directory intact.
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!entry.isFile() || (!entry.name.endsWith(".jsonl") && !entry.name.endsWith(".json"))) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const id = entry.name.replace(/\.jsonl?$/, "");
|
|
118
|
+
if (SAFE_ID.test(id) && isQuickChatSessionId(id))
|
|
119
|
+
ids.add(id);
|
|
120
|
+
}
|
|
121
|
+
for (const id of ids)
|
|
122
|
+
await deleteSessionDir(id, baseDir);
|
|
123
|
+
for (const stagingDir of stagingDirs) {
|
|
124
|
+
await fs.rm(path.join(baseDir, stagingDir), { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
return [...ids, ...stagingDirs];
|
|
127
|
+
}
|
|
128
|
+
function encodeDiskSessionCursor(cursor) {
|
|
129
|
+
return `v1:${cursor.mtime}:${cursor.id}`;
|
|
130
|
+
}
|
|
131
|
+
function decodeDiskSessionCursor(value) {
|
|
132
|
+
if (!value)
|
|
133
|
+
return 0;
|
|
134
|
+
// Keep accepting the old numeric offset during upgrades. New responses use
|
|
135
|
+
// a stable tuple so deleting an item from an earlier page cannot shift and
|
|
136
|
+
// skip a later session.
|
|
137
|
+
if (/^\d+$/.test(value))
|
|
138
|
+
return Number(value);
|
|
139
|
+
const match = /^v1:([^:]+):([A-Za-z0-9._-]+)$/.exec(value);
|
|
140
|
+
if (!match)
|
|
141
|
+
return null;
|
|
142
|
+
const mtime = Number(match[1]);
|
|
143
|
+
return Number.isFinite(mtime) ? { mtime, id: match[2] } : null;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* List top-level (non-sub-agent) sessions from disk, newest first, paginated.
|
|
147
|
+
* Filter on state.json `parentSessionId`:
|
|
148
|
+
* - key absent → legacy → skip (存量 not auto-rebuilt)
|
|
149
|
+
* - null / "" → top-level → show
|
|
150
|
+
* - non-empty → sub-agent → filter out
|
|
151
|
+
*/
|
|
152
|
+
export async function listDiskSessions(opts, baseDir = sessionsRoot()) {
|
|
153
|
+
// Async fs throughout: this is an IPC handler on the Electron main thread
|
|
154
|
+
// (sessions:listDisk). The previous synchronous statSync/readFileSync loops
|
|
155
|
+
// never yielded the event loop and froze the UI while enumerating sessions.
|
|
156
|
+
let entries;
|
|
157
|
+
try {
|
|
158
|
+
entries = await fs.readdir(baseDir, { withFileTypes: true });
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
if (e.code === "ENOENT")
|
|
162
|
+
return { sessions: [], nextCursor: null };
|
|
163
|
+
throw e;
|
|
164
|
+
}
|
|
165
|
+
const candidates = entries.filter((e) => e.isDirectory() && SAFE_ID.test(e.name) && !isQuickChatSessionId(e.name));
|
|
166
|
+
const stats = await Promise.all(candidates.map(async (e) => {
|
|
167
|
+
try {
|
|
168
|
+
return { id: e.name, mtime: (await fs.stat(path.join(baseDir, e.name))).mtimeMs };
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null; // skip unreadable
|
|
172
|
+
}
|
|
173
|
+
}));
|
|
174
|
+
const dirs = stats.filter((d) => d !== null);
|
|
175
|
+
dirs.sort((a, b) => b.mtime - a.mtime || a.id.localeCompare(b.id));
|
|
176
|
+
const decodedCursor = decodeDiskSessionCursor(opts.cursor);
|
|
177
|
+
if (decodedCursor === null)
|
|
178
|
+
return { sessions: [], nextCursor: null };
|
|
179
|
+
const start = typeof decodedCursor === "number"
|
|
180
|
+
? Math.max(0, decodedCursor)
|
|
181
|
+
: (() => {
|
|
182
|
+
const index = dirs.findIndex((entry) => entry.mtime < decodedCursor.mtime ||
|
|
183
|
+
(entry.mtime === decodedCursor.mtime && entry.id > decodedCursor.id));
|
|
184
|
+
return index < 0 ? dirs.length : index;
|
|
185
|
+
})();
|
|
186
|
+
const sessions = [];
|
|
187
|
+
// Cache cwd existence across the loop — many sessions share one project root,
|
|
188
|
+
// and we must not re-stat it per session (nor block on a sync existsSync,
|
|
189
|
+
// which the async-throughout rewrite above was meant to eliminate).
|
|
190
|
+
const cwdExists = new Map();
|
|
191
|
+
const pathExists = async (cwdStr) => {
|
|
192
|
+
const cached = cwdExists.get(cwdStr);
|
|
193
|
+
if (cached !== undefined)
|
|
194
|
+
return cached;
|
|
195
|
+
const ok = await fs
|
|
196
|
+
.access(cwdStr)
|
|
197
|
+
.then(() => true)
|
|
198
|
+
.catch(() => false);
|
|
199
|
+
cwdExists.set(cwdStr, ok);
|
|
200
|
+
return ok;
|
|
201
|
+
};
|
|
202
|
+
let i = start;
|
|
203
|
+
for (; i < dirs.length && sessions.length < opts.limit; i++) {
|
|
204
|
+
const { id, mtime } = dirs[i];
|
|
205
|
+
let state;
|
|
206
|
+
try {
|
|
207
|
+
state = JSON.parse(await fs.readFile(path.join(baseDir, id, "state.json"), "utf8"));
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (state.ephemeral === true)
|
|
213
|
+
continue;
|
|
214
|
+
if (state.kind === "pet")
|
|
215
|
+
continue;
|
|
216
|
+
if (!("parentSessionId" in state))
|
|
217
|
+
continue; // legacy → skip
|
|
218
|
+
if (state.parentSessionId)
|
|
219
|
+
continue; // sub-agent (non-empty) → filter
|
|
220
|
+
// Only desktop + automation belong in the desktop sidebar. tui / missing
|
|
221
|
+
// origin are filtered out (tui shares ~/.code-shell; legacy has no origin).
|
|
222
|
+
const origin = state.origin;
|
|
223
|
+
if (origin !== "desktop" && origin !== "automation")
|
|
224
|
+
continue;
|
|
225
|
+
// Skip sessions whose project root has been deleted. Listing them led the
|
|
226
|
+
// renderer's disk-rebuild to call createRepoForCwd → the deleted project
|
|
227
|
+
// reappeared in the sidebar, and any subsequent write under that cwd
|
|
228
|
+
// resurrected it on disk as an empty shell. An empty cwd (no-repo session)
|
|
229
|
+
// is intentionally NOT filtered here — the renderer handles that via
|
|
230
|
+
// isNoRepoCwd. (deleted-project resurrection)
|
|
231
|
+
const cwdStr = typeof state.cwd === "string" ? state.cwd : "";
|
|
232
|
+
if (cwdStr && !(await pathExists(cwdStr)))
|
|
233
|
+
continue;
|
|
234
|
+
// Archived sessions are hidden from the default catalog. Filtering happens
|
|
235
|
+
// before the push (like every other skip above), so the mtime cursor —
|
|
236
|
+
// derived from the dirs[] position, not from how many rows we kept — keeps
|
|
237
|
+
// advancing across archived rows: a page may return fewer than `limit`
|
|
238
|
+
// sessions, but no live session is skipped and the cursor never stalls.
|
|
239
|
+
const archivedAt = typeof state.archivedAt === "number" ? state.archivedAt : undefined;
|
|
240
|
+
if (archivedAt !== undefined && !opts.includeArchived)
|
|
241
|
+
continue;
|
|
242
|
+
sessions.push({
|
|
243
|
+
id,
|
|
244
|
+
engineSessionId: id,
|
|
245
|
+
cwd: typeof state.cwd === "string" ? state.cwd : "",
|
|
246
|
+
// Prefer the LLM-generated title (now persisted to state); fall back to
|
|
247
|
+
// the raw first-message summary, then the id. This lets the disk rebuild
|
|
248
|
+
// surface the real title after a localStorage wipe.
|
|
249
|
+
title: (typeof state.title === "string" && state.title ? state.title : undefined) ??
|
|
250
|
+
(typeof state.summary === "string" && state.summary ? state.summary : id),
|
|
251
|
+
updatedAt: mtime,
|
|
252
|
+
origin,
|
|
253
|
+
status: state.status === "active" || state.status === "paused" || state.status === "completed"
|
|
254
|
+
? state.status
|
|
255
|
+
: state.status === "aborted_streaming" || state.status === "aborted_tools"
|
|
256
|
+
? "cancelled"
|
|
257
|
+
: typeof state.status === "string"
|
|
258
|
+
? "failed"
|
|
259
|
+
: undefined,
|
|
260
|
+
...(state.status === "completed" &&
|
|
261
|
+
(state.lastCompletionKind === "background_wait" ||
|
|
262
|
+
state.lastCompletionKind === "goal_control_stop" ||
|
|
263
|
+
state.lastCompletionKind === "limit_stop")
|
|
264
|
+
? { completionKind: state.lastCompletionKind }
|
|
265
|
+
: {}),
|
|
266
|
+
...(archivedAt !== undefined ? { archivedAt } : {}),
|
|
267
|
+
...(typeof state.workspaceProfile === "string" && state.workspaceProfile
|
|
268
|
+
? { workspaceProfile: state.workspaceProfile }
|
|
269
|
+
: {}),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
sessions,
|
|
274
|
+
nextCursor: i < dirs.length && i > 0 ? encodeDiskSessionCursor(dirs[i - 1]) : null,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Set (number) or clear (undefined) a session's durable archival marker on
|
|
279
|
+
* disk, then atomically replace `state.json`. This is the main-process writer
|
|
280
|
+
* counterpart to `listDiskSessions`' `archivedAt` filter: main has no live
|
|
281
|
+
* Engine/SessionManager handle, so it mutates the same `state.json` the worker
|
|
282
|
+
* persists. The rest of the state is preserved verbatim; only `archivedAt` is
|
|
283
|
+
* added or removed. Unsafe ids and missing sessions throw.
|
|
284
|
+
*/
|
|
285
|
+
export async function archiveDiskSession(id, archivedAt, baseDir = sessionsRoot()) {
|
|
286
|
+
if (!SAFE_ID.test(id) || id === "." || id === "..") {
|
|
287
|
+
throw new Error(`archiveDiskSession: unsafe session id ${JSON.stringify(id)}`);
|
|
288
|
+
}
|
|
289
|
+
// Route through Core's SessionManager instead of hand-rolling the write.
|
|
290
|
+
//
|
|
291
|
+
// This used to read the whole state.json, set/delete `archivedAt`, and rename a
|
|
292
|
+
// full replacement over it — with no lock and no revision check. A live worker
|
|
293
|
+
// saving token usage, goal, workspace, title or status at the same instant
|
|
294
|
+
// meant two full snapshots racing, and only one survived: archiving could roll
|
|
295
|
+
// back a just-saved field, or a worker save could erase `archivedAt`.
|
|
296
|
+
//
|
|
297
|
+
// `setSessionArchived` → `updateSessionState` already does this correctly: it
|
|
298
|
+
// takes the per-session lock, compares `stateRevision`, re-reads and merges at
|
|
299
|
+
// field level on a CAS miss, and retries. Archiving is a field-level update, so
|
|
300
|
+
// it belongs on exactly that path.
|
|
301
|
+
const manager = new SessionManager(baseDir);
|
|
302
|
+
manager.setSessionArchived(id, archivedAt);
|
|
303
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function contentTypeFor(extname: string): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// packages/server/src/static-files.ts
|
|
2
|
+
//
|
|
3
|
+
// Shared static-file primitives for the two HTTP hosts (pairing host's
|
|
4
|
+
// /mobile static root and the headless serve SPA root). Keep this file free
|
|
5
|
+
// of host wiring — MIME map + helpers only.
|
|
6
|
+
const CONTENT_TYPES = {
|
|
7
|
+
".html": "text/html; charset=utf-8",
|
|
8
|
+
".js": "text/javascript; charset=utf-8",
|
|
9
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
10
|
+
".css": "text/css; charset=utf-8",
|
|
11
|
+
".json": "application/json; charset=utf-8",
|
|
12
|
+
".svg": "image/svg+xml",
|
|
13
|
+
".png": "image/png",
|
|
14
|
+
".jpg": "image/jpeg",
|
|
15
|
+
".jpeg": "image/jpeg",
|
|
16
|
+
".webp": "image/webp",
|
|
17
|
+
".ico": "image/x-icon",
|
|
18
|
+
".map": "application/json",
|
|
19
|
+
".woff": "font/woff",
|
|
20
|
+
".woff2": "font/woff2",
|
|
21
|
+
".ttf": "font/ttf",
|
|
22
|
+
};
|
|
23
|
+
export function contentTypeFor(extname) {
|
|
24
|
+
return CONTENT_TYPES[extname.toLowerCase()] ?? "application/octet-stream";
|
|
25
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/** Truncate a wire line for logs. */
|
|
2
|
+
export declare function previewLine(s: string, max?: number): string;
|
|
3
|
+
export type WorkerBridgeLog = (event: string, data?: Record<string, unknown>) => void;
|
|
4
|
+
export interface WorkerExitInfo {
|
|
5
|
+
code: number | null;
|
|
6
|
+
signal: NodeJS.Signals | null;
|
|
7
|
+
/** true only for code === 0 && signal === null (normal completion). */
|
|
8
|
+
clean: boolean;
|
|
9
|
+
/** true when a crash pushed past the restart limit inside the window. */
|
|
10
|
+
gaveUp: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** Outcome of a correlated JSON-RPC request. Never rejects — callers map. */
|
|
13
|
+
export type WorkerRpcOutcome = {
|
|
14
|
+
status: "result";
|
|
15
|
+
result: unknown;
|
|
16
|
+
} | {
|
|
17
|
+
status: "error";
|
|
18
|
+
error: {
|
|
19
|
+
message?: string;
|
|
20
|
+
code?: number;
|
|
21
|
+
};
|
|
22
|
+
} | {
|
|
23
|
+
status: "timeout";
|
|
24
|
+
}
|
|
25
|
+
/** Sending the frame failed (no live worker, or the write threw). */
|
|
26
|
+
| {
|
|
27
|
+
status: "sendFailed";
|
|
28
|
+
error?: unknown;
|
|
29
|
+
}
|
|
30
|
+
/** Worker exited while pending (only for `settleOnExit: true` requests). */
|
|
31
|
+
| {
|
|
32
|
+
status: "workerExit";
|
|
33
|
+
};
|
|
34
|
+
export interface WorkerRequestOptions {
|
|
35
|
+
/** Wire id for the frame. Caller mints it (must be unique while pending). */
|
|
36
|
+
id: string;
|
|
37
|
+
timeoutMs: number;
|
|
38
|
+
/**
|
|
39
|
+
* true: the matching response line is consumed here and NOT dispatched to
|
|
40
|
+
* line listeners (host-internal RPC, e.g. pet snapshots). false: the line
|
|
41
|
+
* is settled AND still dispatched, so downstream mirrors see it unchanged.
|
|
42
|
+
*/
|
|
43
|
+
consume?: boolean;
|
|
44
|
+
/** Settle with { status: "workerExit" } when the worker dies while pending. */
|
|
45
|
+
settleOnExit?: boolean;
|
|
46
|
+
/** Settle with { status: "sendFailed" } immediately when the frame can't be
|
|
47
|
+
* written; otherwise a failed send leaves the request pending until the
|
|
48
|
+
* timeout (matches fire-and-forget inject semantics). */
|
|
49
|
+
failFast?: boolean;
|
|
50
|
+
/** Spawn the worker on demand (idempotent) before writing the frame. Set for
|
|
51
|
+
* requests that are meant to WAKE the worker — e.g. a pet/IM-gateway
|
|
52
|
+
* `agent/run` — mirroring the renderer's spawn-on-`agent/run` path. Without
|
|
53
|
+
* it a request to a lazily-unspawned worker is dropped and hangs to timeout.
|
|
54
|
+
* Optionally pass the cwd the fresh worker should use. */
|
|
55
|
+
ensureWorker?: boolean;
|
|
56
|
+
/** cwd handed to ensureWorker() when it spawns. Ignored unless ensureWorker. */
|
|
57
|
+
ensureWorkerCwd?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface WorkerBridgeCoreOptions {
|
|
60
|
+
/** Absolute path of the worker entry script (agent-server-stdio). */
|
|
61
|
+
entryPath: string;
|
|
62
|
+
/** Runtime binary; defaults to process.execPath. */
|
|
63
|
+
execPath?: string;
|
|
64
|
+
/** Build the spawn env (called per spawn). Defaults to process.env. */
|
|
65
|
+
buildEnv?: () => NodeJS.ProcessEnv;
|
|
66
|
+
/** cwd used when ensureWorker() is called without one. */
|
|
67
|
+
fallbackCwd?: () => string;
|
|
68
|
+
log?: WorkerBridgeLog;
|
|
69
|
+
/**
|
|
70
|
+
* Rewrite/side-effect hook for injectWorkerMessage lines (e.g. agent/run
|
|
71
|
+
* trust metadata + on-demand spawn). Returns the line to write; `method`
|
|
72
|
+
* is only used for log context.
|
|
73
|
+
*/
|
|
74
|
+
prepareInbound?: (line: string) => {
|
|
75
|
+
line: string;
|
|
76
|
+
method?: string;
|
|
77
|
+
};
|
|
78
|
+
/** A worker successfully spawned with piped stdio. */
|
|
79
|
+
onWorkerStarted?: (info: {
|
|
80
|
+
generation: number;
|
|
81
|
+
pid?: number;
|
|
82
|
+
}) => void;
|
|
83
|
+
/** spawn() threw synchronously, or stdio came back unpiped. No child. */
|
|
84
|
+
onSpawnFailed?: () => void;
|
|
85
|
+
/** The child emitted 'error' (ENOENT/EACCES/…) — no 'exit' will follow. */
|
|
86
|
+
onSpawnError?: (error: unknown) => void;
|
|
87
|
+
onExit?: (info: WorkerExitInfo) => void;
|
|
88
|
+
onStderr?: (text: string) => void;
|
|
89
|
+
restartWindowMs?: number;
|
|
90
|
+
restartLimit?: number;
|
|
91
|
+
}
|
|
92
|
+
export declare class WorkerBridgeCore {
|
|
93
|
+
private readonly opts;
|
|
94
|
+
private child;
|
|
95
|
+
/** Pending lines that arrived before the worker's stdin existed. */
|
|
96
|
+
private outbox;
|
|
97
|
+
private restartCount;
|
|
98
|
+
private restartWindowStart;
|
|
99
|
+
private generation;
|
|
100
|
+
private readonly lineListeners;
|
|
101
|
+
private readonly pendingRequests;
|
|
102
|
+
private readonly restartWindowMs;
|
|
103
|
+
private readonly restartLimit;
|
|
104
|
+
constructor(opts: WorkerBridgeCoreOptions);
|
|
105
|
+
private log;
|
|
106
|
+
/** Current worker generation (0 until the first successful spawn). */
|
|
107
|
+
workerGeneration(): number;
|
|
108
|
+
hasChild(): boolean;
|
|
109
|
+
/** stdin exists and is not destroyed — a line write won't be dropped. */
|
|
110
|
+
canSend(): boolean;
|
|
111
|
+
hasLiveWorker(): boolean;
|
|
112
|
+
/**
|
|
113
|
+
* Spawn the worker if none is alive. `cwd` is the working directory the
|
|
114
|
+
* Engine will use (i.e. the repo root). Idempotent if a child is alive.
|
|
115
|
+
*/
|
|
116
|
+
ensureWorker(requestedCwd?: string): void;
|
|
117
|
+
/** Returns true after >= restartLimit crashes in the current window. */
|
|
118
|
+
private shouldDeclareGaveUp;
|
|
119
|
+
/**
|
|
120
|
+
* If `line` is a response to a pending request(), settle it. Returns true
|
|
121
|
+
* when the line was consumed (must NOT be dispatched to listeners).
|
|
122
|
+
*/
|
|
123
|
+
private settleMatchingRequest;
|
|
124
|
+
private settlePendingOnExit;
|
|
125
|
+
/**
|
|
126
|
+
* Register an observer of every worker stdout line that wasn't consumed by
|
|
127
|
+
* a `consume: true` request. Returns an unsubscribe. A throwing listener
|
|
128
|
+
* never disrupts the stream.
|
|
129
|
+
*/
|
|
130
|
+
subscribeLines(listener: (line: string) => void): () => void;
|
|
131
|
+
/**
|
|
132
|
+
* Write one JSON-RPC line to the worker's stdin (newline appended). Returns
|
|
133
|
+
* false when there is no live worker (caller decides drop/fallback). A
|
|
134
|
+
* stream write error propagates to the caller, matching a direct write.
|
|
135
|
+
*/
|
|
136
|
+
sendLine(line: string): boolean;
|
|
137
|
+
/**
|
|
138
|
+
* Inject a JSON-RPC line into the worker exactly as a first-party front end
|
|
139
|
+
* would. The host's `prepareInbound` hook runs first (metadata rewriting +
|
|
140
|
+
* on-demand spawn side effects); the possibly-rewritten line is then
|
|
141
|
+
* written, or dropped with a log when no worker is alive — identical
|
|
142
|
+
* semantics for every front end (renderer, mobile remote, orchestrators).
|
|
143
|
+
*/
|
|
144
|
+
injectWorkerMessage(rawLine: string): void;
|
|
145
|
+
/**
|
|
146
|
+
* Send a correlated JSON-RPC request and await its response by id.
|
|
147
|
+
* `params` is omitted from the frame when undefined. Never rejects — the
|
|
148
|
+
* outcome union carries error/timeout/send-failure states for the caller
|
|
149
|
+
* to map onto its own semantics.
|
|
150
|
+
*/
|
|
151
|
+
request(method: string, params: unknown, options: WorkerRequestOptions): Promise<WorkerRpcOutcome>;
|
|
152
|
+
/** Graceful shutdown: SIGTERM the worker (no-op when none is alive). */
|
|
153
|
+
kill(): void;
|
|
154
|
+
}
|