@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.
Files changed (70) hide show
  1. package/README.md +77 -0
  2. package/dist/attachment-service.d.ts +80 -0
  3. package/dist/attachment-service.js +587 -0
  4. package/dist/bin/code-shell-serve.d.ts +2 -0
  5. package/dist/bin/code-shell-serve.js +7 -0
  6. package/dist/client-message-id.d.ts +1 -0
  7. package/dist/client-message-id.js +8 -0
  8. package/dist/image-byte-probe.d.ts +17 -0
  9. package/dist/image-byte-probe.js +330 -0
  10. package/dist/index.d.ts +10 -0
  11. package/dist/index.js +10 -0
  12. package/dist/index.mobile-remote.d.ts +21 -0
  13. package/dist/index.mobile-remote.js +21 -0
  14. package/dist/index.serve.d.ts +3 -0
  15. package/dist/index.serve.js +3 -0
  16. package/dist/index.storage.d.ts +10 -0
  17. package/dist/index.storage.js +10 -0
  18. package/dist/index.worker.d.ts +2 -0
  19. package/dist/index.worker.js +2 -0
  20. package/dist/mobile-remote/access-passcode.d.ts +80 -0
  21. package/dist/mobile-remote/access-passcode.js +354 -0
  22. package/dist/mobile-remote/cloudflared-binary.d.ts +76 -0
  23. package/dist/mobile-remote/cloudflared-binary.js +242 -0
  24. package/dist/mobile-remote/codex-parse.d.ts +34 -0
  25. package/dist/mobile-remote/codex-parse.js +131 -0
  26. package/dist/mobile-remote/codex-room-agent.d.ts +94 -0
  27. package/dist/mobile-remote/codex-room-agent.js +213 -0
  28. package/dist/mobile-remote/mobile-attachments.d.ts +21 -0
  29. package/dist/mobile-remote/mobile-attachments.js +119 -0
  30. package/dist/mobile-remote/mobile-chat-turn.d.ts +32 -0
  31. package/dist/mobile-remote/mobile-chat-turn.js +67 -0
  32. package/dist/mobile-remote/mobile-history.d.ts +22 -0
  33. package/dist/mobile-remote/mobile-history.js +27 -0
  34. package/dist/mobile-remote/mobile-run-dispatch.d.ts +17 -0
  35. package/dist/mobile-remote/mobile-run-dispatch.js +56 -0
  36. package/dist/mobile-remote/mobile-static.d.ts +43 -0
  37. package/dist/mobile-remote/mobile-static.js +154 -0
  38. package/dist/mobile-remote/mobile-upload-service.d.ts +57 -0
  39. package/dist/mobile-remote/mobile-upload-service.js +370 -0
  40. package/dist/mobile-remote/pairing.d.ts +8 -0
  41. package/dist/mobile-remote/pairing.js +23 -0
  42. package/dist/mobile-remote/path-bins.d.ts +7 -0
  43. package/dist/mobile-remote/path-bins.js +16 -0
  44. package/dist/mobile-remote/pending-approvals.d.ts +14 -0
  45. package/dist/mobile-remote/pending-approvals.js +54 -0
  46. package/dist/mobile-remote/remote-host-manager.d.ts +117 -0
  47. package/dist/mobile-remote/remote-host-manager.js +345 -0
  48. package/dist/mobile-remote/resident-agent.d.ts +98 -0
  49. package/dist/mobile-remote/resident-agent.js +216 -0
  50. package/dist/mobile-remote/room-manager.d.ts +286 -0
  51. package/dist/mobile-remote/room-manager.js +765 -0
  52. package/dist/mobile-remote/trusted-device-store.d.ts +19 -0
  53. package/dist/mobile-remote/trusted-device-store.js +118 -0
  54. package/dist/mobile-remote/tunnel-manager.d.ts +105 -0
  55. package/dist/mobile-remote/tunnel-manager.js +375 -0
  56. package/dist/mobile-remote/types.d.ts +1 -0
  57. package/dist/mobile-remote/types.js +1 -0
  58. package/dist/mobile-remote/viewer-identity.d.ts +5 -0
  59. package/dist/mobile-remote/viewer-identity.js +5 -0
  60. package/dist/serve/cli.d.ts +15 -0
  61. package/dist/serve/cli.js +115 -0
  62. package/dist/serve/headless-server.d.ts +40 -0
  63. package/dist/serve/headless-server.js +276 -0
  64. package/dist/sessions-service.d.ts +73 -0
  65. package/dist/sessions-service.js +303 -0
  66. package/dist/static-files.d.ts +1 -0
  67. package/dist/static-files.js +25 -0
  68. package/dist/worker-bridge-core.d.ts +154 -0
  69. package/dist/worker-bridge-core.js +315 -0
  70. package/package.json +63 -0
@@ -0,0 +1,315 @@
1
+ // packages/server/src/worker-bridge-core.ts
2
+ //
3
+ // WorkerBridgeCore — transport-agnostic driver for a per-user agent worker
4
+ // subprocess speaking line-delimited JSON-RPC over stdio.
5
+ //
6
+ // This is the Electron-free half of the old AgentBridge, split out so other
7
+ // hosts (e.g. packages/server driving per-user stdio workers) can reuse it.
8
+ // It owns:
9
+ // - child process spawn / respawn (one worker at a time, spawned on demand)
10
+ // - stdout line framing (readline split) + listener dispatch
11
+ // - request/response correlation (id → resolver, with timeout / consume /
12
+ // settle-on-exit semantics)
13
+ // - inbound line injection (`injectWorkerMessage`) with a pluggable
14
+ // `prepareInbound` hook for host-specific rewriting (e.g. agent/run trust)
15
+ // - worker generation counter (bumped on every successful spawn)
16
+ // - pending outbox flushed once the worker's stdin exists
17
+ // - crash accounting (N crashes per rolling window ⇒ "gave up")
18
+ // - graceful shutdown (SIGTERM)
19
+ //
20
+ // It must have ZERO electron imports. All host semantics (renderer fan-out,
21
+ // pet projection, browser/credential intercepts, …) live in the caller via
22
+ // the constructor callbacks and `subscribeLines`.
23
+ import { spawn } from "node:child_process";
24
+ import { createInterface } from "node:readline";
25
+ const DEFAULT_RESTART_WINDOW_MS = 60_000;
26
+ const DEFAULT_RESTART_LIMIT = 3;
27
+ /** Truncate a wire line for logs. */
28
+ export function previewLine(s, max = 200) {
29
+ return s.length > max ? s.slice(0, max) + `…(+${s.length - max} more)` : s;
30
+ }
31
+ export class WorkerBridgeCore {
32
+ opts;
33
+ child = null;
34
+ /** Pending lines that arrived before the worker's stdin existed. */
35
+ outbox = [];
36
+ restartCount = 0;
37
+ restartWindowStart = Date.now();
38
+ generation = 0;
39
+ lineListeners = new Set();
40
+ pendingRequests = new Map();
41
+ restartWindowMs;
42
+ restartLimit;
43
+ constructor(opts) {
44
+ this.opts = opts;
45
+ this.restartWindowMs = opts.restartWindowMs ?? DEFAULT_RESTART_WINDOW_MS;
46
+ this.restartLimit = opts.restartLimit ?? DEFAULT_RESTART_LIMIT;
47
+ }
48
+ log(event, data) {
49
+ this.opts.log?.(event, data);
50
+ }
51
+ /** Current worker generation (0 until the first successful spawn). */
52
+ workerGeneration() {
53
+ return this.generation;
54
+ }
55
+ hasChild() {
56
+ return this.child !== null;
57
+ }
58
+ /** stdin exists and is not destroyed — a line write won't be dropped. */
59
+ canSend() {
60
+ return !!this.child?.stdin && !this.child.stdin.destroyed;
61
+ }
62
+ hasLiveWorker() {
63
+ return !!this.child?.stdin?.writable && !this.child.stdin.destroyed;
64
+ }
65
+ /**
66
+ * Spawn the worker if none is alive. `cwd` is the working directory the
67
+ * Engine will use (i.e. the repo root). Idempotent if a child is alive.
68
+ */
69
+ ensureWorker(requestedCwd) {
70
+ if (this.child)
71
+ return;
72
+ const workerCwd = requestedCwd ?? this.opts.fallbackCwd?.() ?? process.cwd();
73
+ this.log("spawn.start", {
74
+ cwd: workerCwd,
75
+ requestedCwd,
76
+ restartCount: this.restartCount,
77
+ });
78
+ let child;
79
+ try {
80
+ child = spawn(this.opts.execPath ?? process.execPath, [this.opts.entryPath], {
81
+ cwd: workerCwd,
82
+ env: this.opts.buildEnv ? this.opts.buildEnv() : process.env,
83
+ stdio: ["pipe", "pipe", "pipe"],
84
+ });
85
+ }
86
+ catch (e) {
87
+ // spawn() can throw synchronously (e.g. invalid execPath). Don't let it
88
+ // bubble out of the caller uncaught — declare give-up so the host
89
+ // rejects the pending run instead of hanging.
90
+ this.log("spawn.throw", { error: String(e) });
91
+ this.child = null;
92
+ this.opts.onSpawnFailed?.();
93
+ return;
94
+ }
95
+ this.child = child;
96
+ this.log("spawn.ok", { pid: child.pid, cwd: workerCwd, requestedCwd });
97
+ if (!child.stdout || !child.stdin || !child.stderr) {
98
+ this.log("spawn.error", { reason: "stdio not piped" });
99
+ this.child = null;
100
+ this.opts.onSpawnFailed?.();
101
+ return;
102
+ }
103
+ this.generation += 1;
104
+ this.opts.onWorkerStarted?.({ generation: this.generation, pid: child.pid });
105
+ const rl = createInterface({ input: child.stdout });
106
+ rl.on("line", (line) => {
107
+ if (!line.trim())
108
+ return;
109
+ // Request/response correlation first: a consumed response never reaches
110
+ // the listeners (host-internal RPC); a non-consumed one is settled AND
111
+ // still dispatched so downstream mirrors see the identical stream.
112
+ if (this.settleMatchingRequest(line))
113
+ return;
114
+ for (const listener of [...this.lineListeners]) {
115
+ try {
116
+ listener(line);
117
+ }
118
+ catch (e) {
119
+ // A listener must never break worker streaming.
120
+ this.log("line_listener.threw", { error: String(e) });
121
+ }
122
+ }
123
+ });
124
+ child.stderr.on("data", (chunk) => {
125
+ this.opts.onStderr?.(chunk.toString());
126
+ });
127
+ // A failed spawn (ENOENT/EACCES/EAGAIN/process-limit) emits 'error' and
128
+ // NO 'exit'. Without this listener Node throws it as an uncaught
129
+ // exception, and neither exit path fires, so pending host runs hang
130
+ // forever. Treat a spawn error like a give-up crash.
131
+ child.on("error", (err) => {
132
+ this.log("child.error", { error: String(err) });
133
+ try {
134
+ rl.close();
135
+ }
136
+ catch {
137
+ /* ignore */
138
+ }
139
+ this.child = null;
140
+ this.outbox = [];
141
+ this.settlePendingOnExit();
142
+ this.opts.onSpawnError?.(err);
143
+ });
144
+ child.on("exit", (code, signal) => {
145
+ // Close the readline interface bound to the dead child's stdout so it
146
+ // (and its "line" listener) doesn't leak across restarts.
147
+ rl.close();
148
+ this.log("child.exit", { code, signal, pid: child.pid });
149
+ this.child = null;
150
+ this.outbox = []; // any queued messages were for the dead child; drop
151
+ this.settlePendingOnExit();
152
+ if (code === 0 && signal === null) {
153
+ // Normal completion. Reset restart counter — clean exits don't count.
154
+ this.restartCount = 0;
155
+ this.opts.onExit?.({ code, signal, clean: true, gaveUp: false });
156
+ return;
157
+ }
158
+ // Real crash. Note it but DON'T pre-emptively respawn — the next run
159
+ // will trigger a fresh spawn anyway. We just decide whether to declare
160
+ // "gave up" so the host can surface it.
161
+ const gaveUp = this.shouldDeclareGaveUp();
162
+ if (gaveUp)
163
+ this.log("crash.gave_up", { restartCount: this.restartCount });
164
+ else
165
+ this.log("crash.tolerable", { restartCount: this.restartCount });
166
+ this.opts.onExit?.({ code, signal, clean: false, gaveUp });
167
+ });
168
+ // Flush queued lines now that stdin exists.
169
+ for (const queued of this.outbox) {
170
+ child.stdin.write(queued + "\n");
171
+ }
172
+ this.outbox = [];
173
+ }
174
+ /** Returns true after >= restartLimit crashes in the current window. */
175
+ shouldDeclareGaveUp() {
176
+ const now = Date.now();
177
+ if (now - this.restartWindowStart > this.restartWindowMs) {
178
+ this.restartWindowStart = now;
179
+ this.restartCount = 0;
180
+ }
181
+ this.restartCount++;
182
+ return this.restartCount > this.restartLimit;
183
+ }
184
+ /**
185
+ * If `line` is a response to a pending request(), settle it. Returns true
186
+ * when the line was consumed (must NOT be dispatched to listeners).
187
+ */
188
+ settleMatchingRequest(line) {
189
+ if (this.pendingRequests.size === 0)
190
+ return false;
191
+ let msg;
192
+ try {
193
+ msg = JSON.parse(line);
194
+ }
195
+ catch {
196
+ return false;
197
+ }
198
+ if (msg.id === undefined || msg.id === null)
199
+ return false;
200
+ const pending = this.pendingRequests.get(msg.id);
201
+ if (!pending)
202
+ return false;
203
+ pending.settle(msg.error
204
+ ? { status: "error", error: msg.error }
205
+ : { status: "result", result: msg.result });
206
+ return pending.consume;
207
+ }
208
+ settlePendingOnExit() {
209
+ for (const pending of [...this.pendingRequests.values()]) {
210
+ if (pending.settleOnExit)
211
+ pending.settle({ status: "workerExit" });
212
+ }
213
+ }
214
+ /**
215
+ * Register an observer of every worker stdout line that wasn't consumed by
216
+ * a `consume: true` request. Returns an unsubscribe. A throwing listener
217
+ * never disrupts the stream.
218
+ */
219
+ subscribeLines(listener) {
220
+ this.lineListeners.add(listener);
221
+ return () => this.lineListeners.delete(listener);
222
+ }
223
+ /**
224
+ * Write one JSON-RPC line to the worker's stdin (newline appended). Returns
225
+ * false when there is no live worker (caller decides drop/fallback). A
226
+ * stream write error propagates to the caller, matching a direct write.
227
+ */
228
+ sendLine(line) {
229
+ const stdin = this.child?.stdin;
230
+ if (!stdin || stdin.destroyed)
231
+ return false;
232
+ stdin.write(line + "\n");
233
+ return true;
234
+ }
235
+ /**
236
+ * Inject a JSON-RPC line into the worker exactly as a first-party front end
237
+ * would. The host's `prepareInbound` hook runs first (metadata rewriting +
238
+ * on-demand spawn side effects); the possibly-rewritten line is then
239
+ * written, or dropped with a log when no worker is alive — identical
240
+ * semantics for every front end (renderer, mobile remote, orchestrators).
241
+ */
242
+ injectWorkerMessage(rawLine) {
243
+ const prep = this.opts.prepareInbound
244
+ ? this.opts.prepareInbound(rawLine)
245
+ : { line: rawLine, method: undefined };
246
+ if (!this.canSend()) {
247
+ this.log("inject.dropped", {
248
+ reason: this.child ? "stdin destroyed" : "no child",
249
+ method: prep.method,
250
+ });
251
+ return;
252
+ }
253
+ this.log("inject→worker", { method: prep.method, raw: previewLine(prep.line) });
254
+ this.sendLine(prep.line);
255
+ }
256
+ /**
257
+ * Send a correlated JSON-RPC request and await its response by id.
258
+ * `params` is omitted from the frame when undefined. Never rejects — the
259
+ * outcome union carries error/timeout/send-failure states for the caller
260
+ * to map onto its own semantics.
261
+ */
262
+ request(method, params, options) {
263
+ const { id } = options;
264
+ return new Promise((resolve) => {
265
+ let settled = false;
266
+ const settle = (outcome) => {
267
+ if (settled)
268
+ return;
269
+ settled = true;
270
+ clearTimeout(timer);
271
+ this.pendingRequests.delete(id);
272
+ resolve(outcome);
273
+ };
274
+ const timer = setTimeout(() => settle({ status: "timeout" }), options.timeoutMs);
275
+ // unref so a long-lived correlation can never be the only thing keeping the
276
+ // process alive. Callers legitimately use very long timeouts (the Panel App
277
+ // fire-and-forget agent/run uses 24h); without this, a pending request of
278
+ // that length blocks a clean exit for a day. Settling still works normally
279
+ // while the process is running — unref only removes the event-loop hold.
280
+ timer.unref?.();
281
+ this.pendingRequests.set(id, {
282
+ consume: options.consume === true,
283
+ settleOnExit: options.settleOnExit === true,
284
+ settle,
285
+ });
286
+ const frame = JSON.stringify({
287
+ jsonrpc: "2.0",
288
+ id,
289
+ method,
290
+ ...(params === undefined ? {} : { params }),
291
+ });
292
+ // Wake a lazily-unspawned worker before writing, so a spawn-triggering
293
+ // request (pet/IM-gateway agent/run) isn't dropped by sendLine and left
294
+ // to hang until timeout. Idempotent when a child is already alive.
295
+ if (options.ensureWorker)
296
+ this.ensureWorker(options.ensureWorkerCwd);
297
+ let sent = false;
298
+ let sendError;
299
+ try {
300
+ sent = this.sendLine(frame);
301
+ }
302
+ catch (e) {
303
+ sendError = e;
304
+ }
305
+ if (options.failFast && (!sent || sendError !== undefined)) {
306
+ settle({ status: "sendFailed", error: sendError });
307
+ }
308
+ });
309
+ }
310
+ /** Graceful shutdown: SIGTERM the worker (no-op when none is alive). */
311
+ kill() {
312
+ this.log("kill", { pid: this.child?.pid });
313
+ this.child?.kill("SIGTERM");
314
+ }
315
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@cjhyy/code-shell-server",
3
+ "version": "0.8.0",
4
+ "description": "CodeShell server transport layer — mobile remote HTTP/WS host, pairing, passcode gate, tunnel, rooms, uploads, and session/attachment services. Electron-free; precursor of the auth gateway.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./storage": {
14
+ "types": "./dist/index.storage.d.ts",
15
+ "import": "./dist/index.storage.js"
16
+ },
17
+ "./worker": {
18
+ "types": "./dist/index.worker.d.ts",
19
+ "import": "./dist/index.worker.js"
20
+ },
21
+ "./mobile-remote": {
22
+ "types": "./dist/index.mobile-remote.d.ts",
23
+ "import": "./dist/index.mobile-remote.js"
24
+ },
25
+ "./serve": {
26
+ "types": "./dist/index.serve.d.ts",
27
+ "import": "./dist/index.serve.js"
28
+ }
29
+ },
30
+ "bin": {
31
+ "code-shell-serve": "./dist/bin/code-shell-serve.js"
32
+ },
33
+ "files": [
34
+ "dist"
35
+ ],
36
+ "scripts": {
37
+ "build": "bun run clean && tsc -p tsconfig.json",
38
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
39
+ },
40
+ "dependencies": {
41
+ "@cjhyy/code-shell-capability-coding": "0.8.0",
42
+ "@cjhyy/code-shell-core": "0.8.0",
43
+ "@cjhyy/code-shell-web": "0.8.0",
44
+ "@types/ws": "^8.18.1",
45
+ "ws": "^8.21.0"
46
+ },
47
+ "engines": {
48
+ "node": ">=20.10"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/cjhyy/codeshell.git",
53
+ "directory": "packages/server"
54
+ },
55
+ "homepage": "https://github.com/cjhyy/codeshell/tree/main/packages/server#readme",
56
+ "bugs": {
57
+ "url": "https://github.com/cjhyy/codeshell/issues"
58
+ },
59
+ "publishConfig": {
60
+ "access": "public"
61
+ },
62
+ "license": "MIT"
63
+ }