nebula-notebook 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +110 -16
  2. package/dist/assets/errorwidget-Wy5ajPiM.js +5 -0
  3. package/dist/assets/fa-brands-400-CEJbCg16.woff +0 -0
  4. package/dist/assets/fa-brands-400-CSYNqBb_.ttf +0 -0
  5. package/dist/assets/fa-brands-400-DnkPfk3o.eot +0 -0
  6. package/dist/assets/fa-brands-400-UxlILjvJ.woff2 +0 -0
  7. package/dist/assets/fa-brands-400-cH1MgKbP.svg +3717 -0
  8. package/dist/assets/fa-regular-400-BhTwtT8w.eot +0 -0
  9. package/dist/assets/fa-regular-400-D1vz6WBx.ttf +0 -0
  10. package/dist/assets/fa-regular-400-DFnMcJPd.woff +0 -0
  11. package/dist/assets/fa-regular-400-DGzu1beS.woff2 +0 -0
  12. package/dist/assets/fa-regular-400-gwj8Pxq-.svg +801 -0
  13. package/dist/assets/fa-solid-900-B4ZZ7kfP.svg +5034 -0
  14. package/dist/assets/fa-solid-900-B6Axprfb.eot +0 -0
  15. package/dist/assets/fa-solid-900-BUswJgRo.woff2 +0 -0
  16. package/dist/assets/fa-solid-900-DOXgCApm.woff +0 -0
  17. package/dist/assets/fa-solid-900-mxuxnBEa.ttf +0 -0
  18. package/dist/assets/index-B_Bs5koo.js +1 -0
  19. package/dist/assets/index-BtWv4MIT.css +7 -0
  20. package/dist/assets/index-DK4nS74r.js +829 -0
  21. package/dist/assets/index-DeQN03nW.js +81 -0
  22. package/dist/assets/index-DzjpmUak.css +32 -0
  23. package/dist/assets/index-g6MjT-VG.js +1 -0
  24. package/dist/assets/services-shim-BirHVsih.js +33 -0
  25. package/dist/assets/viewlist-uomDf7I7.js +1 -0
  26. package/dist/assets/widgets-X7J3NxEn.css +1 -0
  27. package/dist/index.html +2 -2
  28. package/node-server/dist/auth/auth-middleware.js +22 -4
  29. package/node-server/dist/auth/auth-service.js +37 -7
  30. package/node-server/dist/cluster/client-registration.d.ts +12 -1
  31. package/node-server/dist/cluster/client-registration.js +38 -2
  32. package/node-server/dist/cluster/kernel-proxy.js +43 -12
  33. package/node-server/dist/cluster/server-registry.d.ts +17 -0
  34. package/node-server/dist/cluster/server-registry.js +57 -7
  35. package/node-server/dist/discovery/conda-locations.d.ts +73 -0
  36. package/node-server/dist/discovery/conda-locations.js +427 -0
  37. package/node-server/dist/discovery/discovery-service.d.ts +73 -4
  38. package/node-server/dist/discovery/discovery-service.js +211 -127
  39. package/node-server/dist/discovery/types.d.ts +0 -1
  40. package/node-server/dist/discovery/types.js +1 -2
  41. package/node-server/dist/environment.d.ts +12 -0
  42. package/node-server/dist/environment.js +104 -0
  43. package/node-server/dist/fs/fs-service.d.ts +86 -8
  44. package/node-server/dist/fs/fs-service.js +640 -99
  45. package/node-server/dist/fs/notebook-formats/percent.d.ts +25 -0
  46. package/node-server/dist/fs/notebook-formats/percent.js +286 -0
  47. package/node-server/dist/fs/notebook-formats/qmd.d.ts +29 -0
  48. package/node-server/dist/fs/notebook-formats/qmd.js +307 -0
  49. package/node-server/dist/fs/notebook-formats/registry.d.ts +12 -0
  50. package/node-server/dist/fs/notebook-formats/registry.js +77 -0
  51. package/node-server/dist/fs/notebook-formats/types.d.ts +37 -0
  52. package/node-server/dist/fs/notebook-formats/types.js +13 -0
  53. package/node-server/dist/fs/types.d.ts +9 -0
  54. package/node-server/dist/fs/types.js +8 -0
  55. package/node-server/dist/idle-exit.d.ts +52 -0
  56. package/node-server/dist/idle-exit.js +83 -0
  57. package/node-server/dist/index.js +198 -8
  58. package/node-server/dist/kernel/kernel-service.d.ts +130 -2
  59. package/node-server/dist/kernel/kernel-service.js +834 -64
  60. package/node-server/dist/kernel/kernelspec.d.ts +27 -0
  61. package/node-server/dist/kernel/kernelspec.js +75 -4
  62. package/node-server/dist/notebook/headless-handler.d.ts +12 -0
  63. package/node-server/dist/notebook/headless-handler.js +125 -29
  64. package/node-server/dist/notebook/operation-router.js +7 -2
  65. package/node-server/dist/notebook/undoRedoManager.d.ts +12 -1
  66. package/node-server/dist/notebook/undoRedoManager.js +49 -18
  67. package/node-server/dist/output/display-data.js +2 -0
  68. package/node-server/dist/private-tmp.d.ts +14 -0
  69. package/node-server/dist/private-tmp.js +70 -0
  70. package/node-server/dist/routes/autocomplete.d.ts +19 -0
  71. package/node-server/dist/routes/autocomplete.js +294 -0
  72. package/node-server/dist/routes/cluster.js +2 -2
  73. package/node-server/dist/routes/compute.d.ts +8 -0
  74. package/node-server/dist/routes/compute.js +136 -0
  75. package/node-server/dist/routes/fs.js +23 -6
  76. package/node-server/dist/routes/kernel.js +156 -3
  77. package/node-server/dist/routes/notebook.js +37 -3
  78. package/node-server/dist/routes/python.d.ts +1 -2
  79. package/node-server/dist/routes/python.js +139 -3
  80. package/node-server/dist/scheduler/allocation-service.d.ts +63 -0
  81. package/node-server/dist/scheduler/allocation-service.js +285 -0
  82. package/node-server/dist/scheduler/job-template.d.ts +30 -0
  83. package/node-server/dist/scheduler/job-template.js +85 -0
  84. package/node-server/dist/scheduler/mock-scheduler.d.ts +30 -0
  85. package/node-server/dist/scheduler/mock-scheduler.js +125 -0
  86. package/node-server/dist/scheduler/slurm-scheduler.d.ts +31 -0
  87. package/node-server/dist/scheduler/slurm-scheduler.js +406 -0
  88. package/node-server/dist/scheduler/types.d.ts +118 -0
  89. package/node-server/dist/scheduler/types.js +8 -0
  90. package/node-server/dist/scheduler/util.d.ts +7 -0
  91. package/node-server/dist/scheduler/util.js +20 -0
  92. package/node-server/dist/terminal/agent-registry.d.ts +87 -0
  93. package/node-server/dist/terminal/agent-registry.js +246 -0
  94. package/node-server/dist/terminal/binding-store.d.ts +57 -0
  95. package/node-server/dist/terminal/binding-store.js +0 -0
  96. package/node-server/dist/terminal/pty-manager.d.ts +23 -6
  97. package/node-server/dist/terminal/pty-manager.js +138 -19
  98. package/node-server/dist/terminal/server.js +155 -23
  99. package/node-server/dist/terminal/types.d.ts +2 -0
  100. package/node-server/dist/update-check.d.ts +20 -0
  101. package/node-server/dist/update-check.js +114 -0
  102. package/node-server/package.json +2 -0
  103. package/node_modules/nebula-autocomplete/README.md +145 -0
  104. package/node_modules/nebula-autocomplete/dist/backends/claude.d.ts +69 -0
  105. package/node_modules/nebula-autocomplete/dist/backends/claude.js +449 -0
  106. package/node_modules/nebula-autocomplete/dist/backends/codex.d.ts +45 -0
  107. package/node_modules/nebula-autocomplete/dist/backends/codex.js +108 -0
  108. package/node_modules/nebula-autocomplete/dist/client/fetcher.d.ts +16 -0
  109. package/node_modules/nebula-autocomplete/dist/client/fetcher.js +41 -0
  110. package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.d.ts +43 -0
  111. package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.js +175 -0
  112. package/node_modules/nebula-autocomplete/dist/core/engine.d.ts +17 -0
  113. package/node_modules/nebula-autocomplete/dist/core/engine.js +135 -0
  114. package/node_modules/nebula-autocomplete/dist/core/lru.d.ts +7 -0
  115. package/node_modules/nebula-autocomplete/dist/core/lru.js +26 -0
  116. package/node_modules/nebula-autocomplete/dist/core/prompt.d.ts +12 -0
  117. package/node_modules/nebula-autocomplete/dist/core/prompt.js +0 -0
  118. package/node_modules/nebula-autocomplete/dist/core/text.d.ts +53 -0
  119. package/node_modules/nebula-autocomplete/dist/core/text.js +169 -0
  120. package/node_modules/nebula-autocomplete/dist/index.d.ts +7 -0
  121. package/node_modules/nebula-autocomplete/dist/index.js +5 -0
  122. package/node_modules/nebula-autocomplete/dist/server/fastify.d.ts +40 -0
  123. package/node_modules/nebula-autocomplete/dist/server/fastify.js +52 -0
  124. package/node_modules/nebula-autocomplete/dist/transport.d.ts +55 -0
  125. package/node_modules/nebula-autocomplete/dist/transport.js +66 -0
  126. package/node_modules/nebula-autocomplete/dist/types.d.ts +103 -0
  127. package/node_modules/nebula-autocomplete/dist/types.js +1 -0
  128. package/node_modules/nebula-autocomplete/package.json +58 -0
  129. package/node_modules/nebula-autocomplete/server.d.ts +3 -0
  130. package/node_modules/nebula-autocomplete/server.js +3 -0
  131. package/package.json +18 -5
  132. package/scripts/postinstall.cjs +25 -5
  133. package/dist/assets/index-BvrHu37J.js +0 -648
  134. package/dist/assets/index-Dfj_xsKU.css +0 -32
  135. package/node-server/dist/llm/index.d.ts +0 -5
  136. package/node-server/dist/llm/index.js +0 -21
  137. package/node-server/dist/llm/llm-service.d.ts +0 -77
  138. package/node-server/dist/llm/llm-service.js +0 -454
  139. package/node-server/dist/llm/types.d.ts +0 -40
  140. package/node-server/dist/llm/types.js +0 -15
  141. package/node-server/dist/routes/llm.d.ts +0 -8
  142. package/node-server/dist/routes/llm.js +0 -105
@@ -0,0 +1,449 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdirSync, readdirSync, rmSync } from "node:fs";
3
+ import { randomUUID } from "node:crypto";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { transcriptDirMatchesToken } from "../core/text.js";
7
+ import { planSpawn, planRemoteCleanup } from "../transport.js";
8
+ function isWorkerDeath(err) {
9
+ return err instanceof Error && err.message === "claude worker exited";
10
+ }
11
+ class ClaudeWorker {
12
+ opts;
13
+ busy = false;
14
+ dead = false;
15
+ turns = 0;
16
+ /** Total prompt chars sent — the real recycle driver: stream-json history
17
+ * grows with every prompt, and TTFB grows with HISTORY SIZE, not turn
18
+ * count (measured: 6.4KB prompts drag +~0.2s/turn; 1.5KB prompts don't). */
19
+ sentChars = 0;
20
+ bornAt = Date.now();
21
+ /** True once the warmup turn has settled — pool selection prefers warmed
22
+ * workers so a request never waits ~5s on a still-booting one while a
23
+ * warm sibling sits idle (observed: workerWait 4506ms, pool 0/2 busy). */
24
+ warmedDone = false;
25
+ /** Invoked once when the process exits/errors — lets the pool respawn in the background. */
26
+ onDeath;
27
+ warmed;
28
+ // Each worker runs in its OWN cwd subdir so Claude's per-cwd transcript
29
+ // directory is unique per worker. Pruning a recycled worker's transcripts
30
+ // then can't touch a concurrently-active worker's dir. cwdToken is the
31
+ // subdir name — it appears in the munged transcript-dir name.
32
+ cwd;
33
+ cwdToken = randomUUID();
34
+ pending = null;
35
+ buf = "";
36
+ proc;
37
+ // For the ssh transport: an ephemeral config dir on the REMOTE side so claude's
38
+ // transcripts don't accumulate on the user's machine — wiped on kill().
39
+ remoteConfigDir;
40
+ constructor(opts) {
41
+ this.opts = opts;
42
+ const argv = [
43
+ "-p",
44
+ "--model", opts.model,
45
+ // Strip tools and MCP servers from the harness prompt — the largest
46
+ // per-turn latency saver (measured ~2.3s -> ~1.4s TTFB).
47
+ "--tools", "",
48
+ "--mcp-config", '{"mcpServers":{}}',
49
+ "--strict-mcp-config",
50
+ "--input-format", "stream-json",
51
+ "--output-format", "stream-json",
52
+ "--include-partial-messages",
53
+ "--verbose",
54
+ ];
55
+ const env = {
56
+ // Generous cap: hitting it surfaces as an ERROR that discards the turn,
57
+ // so a tight cap converts long completions into nothing (observed).
58
+ // Runaways are bounded by the turn timeout and by supersession
59
+ // interrupts instead; long ghost text streams visibly and the user can
60
+ // ignore or Escape it.
61
+ CLAUDE_CODE_MAX_OUTPUT_TOKENS: "4096",
62
+ // Default 0: thinking tokens never stream as ghost text — observed as
63
+ // a 17s turn burning the whole budget with NOTHING shown. Users can
64
+ // opt into a budget (Advanced) for higher-quality completions.
65
+ MAX_THINKING_TOKENS: String(opts.maxThinkingTokens ?? 0),
66
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
67
+ };
68
+ if (opts.transport.kind === "ssh") {
69
+ // Remote: run in a fresh remote tmp dir; keep transcripts in an ephemeral
70
+ // remote config dir (auth still comes from the user's real login via their
71
+ // ~/.zshenv token). No local workspace dir.
72
+ this.cwd = `/tmp/nebula-autocomplete-ws-${this.cwdToken}`;
73
+ this.remoteConfigDir = `/tmp/nebula-autocomplete-cfg-${this.cwdToken}`;
74
+ env.CLAUDE_CONFIG_DIR = this.remoteConfigDir;
75
+ }
76
+ else {
77
+ this.cwd = join(opts.workspaceDir, this.cwdToken);
78
+ this.remoteConfigDir = "";
79
+ mkdirSync(this.cwd, { recursive: true });
80
+ }
81
+ const plan = planSpawn(opts.transport, opts.binary, argv, env, this.cwd);
82
+ this.proc = spawn(plan.command, plan.args, {
83
+ ...plan.options,
84
+ stdio: ["pipe", "pipe", "pipe"],
85
+ });
86
+ this.proc.stdout.on("data", (d) => this.onData(d));
87
+ this.proc.on("exit", () => {
88
+ this.dead = true;
89
+ this.pending?.reject(new Error("claude worker exited"));
90
+ this.pending = null;
91
+ const cb = this.onDeath;
92
+ this.onDeath = undefined; // fire once
93
+ cb?.();
94
+ });
95
+ this.proc.on("error", (err) => {
96
+ this.dead = true;
97
+ this.pending?.reject(err instanceof Error ? err : new Error(String(err)));
98
+ this.pending = null;
99
+ const cb = this.onDeath;
100
+ this.onDeath = undefined;
101
+ cb?.();
102
+ });
103
+ this.warmed = this.runTurn("Reply with exactly: ok").then(() => { this.warmedDone = true; }, () => { this.warmedDone = true; });
104
+ }
105
+ onData(data) {
106
+ this.buf += data.toString();
107
+ let idx;
108
+ while ((idx = this.buf.indexOf("\n")) !== -1) {
109
+ const line = this.buf.slice(0, idx);
110
+ this.buf = this.buf.slice(idx + 1);
111
+ if (!line.trim() || !this.pending)
112
+ continue;
113
+ let ev;
114
+ try {
115
+ ev = JSON.parse(line);
116
+ }
117
+ catch {
118
+ continue;
119
+ }
120
+ if (ev.type === "stream_event") {
121
+ const event = ev.event;
122
+ if (event?.type === "content_block_delta" && event.delta?.type === "text_delta") {
123
+ this.pending.streamed = (this.pending.streamed ?? "") + event.delta.text;
124
+ this.pending.onChunk?.(event.delta.text);
125
+ }
126
+ }
127
+ else if (ev.type === "result") {
128
+ const p = this.pending;
129
+ this.pending = null;
130
+ clearTimeout(p.timer);
131
+ // CLI failures (not logged in, out of credits, …) arrive as a result
132
+ // event with is_error / non-success subtype. Reject those turns —
133
+ // resolving would cache and render the error text as a suggestion.
134
+ if (ev.is_error === true || (typeof ev.subtype === "string" && ev.subtype !== "success")) {
135
+ const msg = typeof ev.result === "string" && ev.result.trim()
136
+ ? ev.result.trim()
137
+ : `claude turn failed (${ev.subtype ?? "error"})`;
138
+ // Hitting the output-token cap arrives as an ERROR, discarding a
139
+ // completion we already streamed in full (observed: 35s turn, 512
140
+ // tokens, then thrown away). The streamed text IS the completion —
141
+ // truncated, but the tag/trim pipeline handles a missing close tag.
142
+ if (/output token maximum/i.test(msg) && p.streamed) {
143
+ p.resolve(p.streamed);
144
+ }
145
+ else {
146
+ const note = /output token maximum/i.test(msg)
147
+ ? ` (streamed ${p.streamed?.length ?? 0}ch before the cap)`
148
+ : "";
149
+ p.reject(new Error(msg + note));
150
+ }
151
+ }
152
+ else {
153
+ p.resolve(typeof ev.result === "string" ? ev.result : "");
154
+ }
155
+ }
156
+ }
157
+ }
158
+ runTurn(text, onChunk, signal) {
159
+ return new Promise((resolve, reject) => {
160
+ if (this.dead)
161
+ return reject(new Error("claude worker is dead"));
162
+ const timer = setTimeout(() => {
163
+ this.pending = null;
164
+ this.kill();
165
+ reject(new Error("claude turn timed out"));
166
+ }, this.opts.turnTimeoutMs);
167
+ const pending = { onChunk, resolve, reject, timer };
168
+ // Supersession: a dispatched turn CAN be cancelled — the stream-json
169
+ // protocol accepts {type:"control_request",request:{subtype:"interrupt"}}
170
+ // and ends the running turn in ~10ms (measured), leaving the worker
171
+ // reusable. Without this, a superseded completion held its worker for
172
+ // the full turn and the successor's "queued" time was a whole turn.
173
+ // Guard on `this.pending === pending` so a late abort can never kill a
174
+ // successor's turn that reused this worker.
175
+ const onAbort = () => {
176
+ if (this.pending === pending && !this.dead) {
177
+ try {
178
+ this.proc.stdin.write(JSON.stringify({
179
+ type: "control_request",
180
+ request_id: `int-${Date.now()}`,
181
+ request: { subtype: "interrupt" },
182
+ }) + "\n");
183
+ }
184
+ catch { /* worker dying anyway */ }
185
+ }
186
+ };
187
+ signal?.addEventListener("abort", onAbort, { once: true });
188
+ const cleanup = () => signal?.removeEventListener("abort", onAbort);
189
+ pending.resolve = (v) => { cleanup(); resolve(v); };
190
+ pending.reject = (e) => { cleanup(); reject(e); };
191
+ this.pending = pending;
192
+ if (signal?.aborted)
193
+ onAbort();
194
+ const msg = {
195
+ type: "user",
196
+ message: { role: "user", content: [{ type: "text", text }] },
197
+ };
198
+ this.proc.stdin.write(JSON.stringify(msg) + "\n");
199
+ });
200
+ }
201
+ kill() {
202
+ this.dead = true;
203
+ try {
204
+ this.proc.kill();
205
+ }
206
+ catch {
207
+ /* already gone */
208
+ }
209
+ // Remote no-trace: wipe this worker's ephemeral config + workspace dirs on
210
+ // the user's machine. Fire-and-forget over the (still-warm) control channel.
211
+ if (this.opts.transport.kind === "ssh") {
212
+ for (const dir of [this.remoteConfigDir, this.cwd]) {
213
+ if (!dir)
214
+ continue;
215
+ try {
216
+ const c = planRemoteCleanup(this.opts.transport, dir);
217
+ spawn(c.command, c.args, { stdio: "ignore" }).unref();
218
+ }
219
+ catch {
220
+ /* best-effort */
221
+ }
222
+ }
223
+ }
224
+ }
225
+ }
226
+ /**
227
+ * Persistent warm pool of `claude` CLI processes (subscription auth) in
228
+ * stream-json mode. Workers are recycled after maxTurnsPerWorker completions
229
+ * so conversation history never bloats the context.
230
+ */
231
+ export class ClaudeBackend {
232
+ name = "claude";
233
+ pool = [];
234
+ opts;
235
+ disposed = false;
236
+ // Unique token embedded in the workspace path. Claude Code keys its session
237
+ // transcript directory (~/.claude/projects/<munged-cwd>) off the process cwd,
238
+ // so this token survives path munging and lets us find and delete only our
239
+ // own transcript dirs — never a real session. See pruneTranscripts().
240
+ wsToken = randomUUID();
241
+ constructor(options = {}) {
242
+ // For ssh transport, give all workers a shared ControlMaster socket so the
243
+ // handshake is paid once and reconnects are ~free.
244
+ let transport = options.transport ?? { kind: "local" };
245
+ if (transport.kind === "ssh" && !transport.controlPath) {
246
+ // Keep the ControlMaster socket path SHORT: unix-socket paths max ~104
247
+ // chars, and macOS tmpdir() is a long /var/folders/... path that overflows
248
+ // it (→ ssh aborts). /tmp + a short token stays well under the limit.
249
+ transport = { ...transport, controlPath: `/tmp/nac-${this.wsToken.slice(0, 8)}` };
250
+ }
251
+ this.opts = {
252
+ model: options.model ?? "haiku",
253
+ poolSize: options.poolSize ?? 2,
254
+ // MEASURED (10 sequential real turns, one worker): per-turn latency does
255
+ // NOT grow with turn count — turns 7-9 averaged FASTER than 0-2, so the
256
+ // CLI/API handles its own history efficiently (prompt caching). Keep 8:
257
+ // recycling more often would just burn a warmup API call per recycle
258
+ // for zero latency benefit.
259
+ maxTurnsPerWorker: options.maxTurnsPerWorker ?? 8,
260
+ // MEASURED: TTFB grows with accumulated HISTORY, not turns — 6.4KB
261
+ // real-notebook prompts dragged ttfb 3.2s@turn5 -> 4.0s@turn7, while a
262
+ // 10-turn run of 1.5KB prompts showed no drag at all. Cap history size;
263
+ // the turn cap above is just a backstop. Recycling is background-warmed
264
+ // (spawnWorker/ensurePool), so tighter recycling costs the user nothing.
265
+ maxHistoryCharsPerWorker: options.maxHistoryCharsPerWorker ?? 18_000,
266
+ turnTimeoutMs: options.turnTimeoutMs ?? 45_000,
267
+ // Default to a per-instance dir so the transcript-dir name is unique to
268
+ // this backend. A caller-supplied dir is used verbatim (its transcripts
269
+ // are then only pruned if its basename happens to contain wsToken — so
270
+ // prefer letting the default apply for full hygiene).
271
+ workspaceDir: options.workspaceDir ?? join(tmpdir(), `nebula-autocomplete-ws-${this.wsToken}`),
272
+ binary: options.binary ?? "claude",
273
+ transport,
274
+ maxThinkingTokens: options.maxThinkingTokens ?? 0,
275
+ };
276
+ // Local transport keeps its worker workspaces under workspaceDir; ssh workers
277
+ // use remote dirs, so no local dir is needed.
278
+ if (transport.kind === "local")
279
+ mkdirSync(this.opts.workspaceDir, { recursive: true });
280
+ this.ensurePool();
281
+ }
282
+ /**
283
+ * Delete any Claude Code session-transcript directories that belong to this
284
+ * backend. Claude writes `~/.claude/projects/<munged-cwd>/*.jsonl` on some
285
+ * versions/platforms; the munged name preserves alphanumerics, so our
286
+ * wsToken (a UUID) always survives. We match by comparing alphanumeric-only
287
+ * forms, which is munge-algorithm-independent and cannot collide with a real
288
+ * project path. Best-effort: never throws into the caller.
289
+ */
290
+ pruneTranscripts(token = this.wsToken) {
291
+ // ssh transport: transcripts live on the remote machine in a per-worker
292
+ // ephemeral CLAUDE_CONFIG_DIR, wiped by ClaudeWorker.kill(). Nothing local.
293
+ if (this.opts.transport.kind === "ssh")
294
+ return;
295
+ try {
296
+ const projectsDir = join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects");
297
+ for (const entry of readdirSync(projectsDir)) {
298
+ if (transcriptDirMatchesToken(entry, token)) {
299
+ rmSync(join(projectsDir, entry), { recursive: true, force: true });
300
+ }
301
+ }
302
+ }
303
+ catch {
304
+ /* projects dir absent (nothing written) or unreadable — nothing to prune */
305
+ }
306
+ }
307
+ ensurePool() {
308
+ if (this.disposed)
309
+ return;
310
+ this.pool = this.pool.filter((w) => !w.dead);
311
+ while (this.pool.length < this.opts.poolSize) {
312
+ this.pool.push(this.spawnWorker());
313
+ }
314
+ }
315
+ /**
316
+ * Spawn a worker wired for background self-healing: the claude CLI exits on
317
+ * its own after long idle, and replacing it lazily (at the next request)
318
+ * put the whole cold start — process boot + ssh reconnect + warmup turn —
319
+ * on the user's first post-idle completion (multi-second "autocomplete is
320
+ * dead after I come back" lag). Respawning the moment a worker dies keeps
321
+ * the pool warm through idle, so the first keystroke after a break pays the
322
+ * same ~1.5s as any other. Crash-loop guard: only self-respawn for workers
323
+ * that were healthy (survived 60s or completed a turn) — a broken binary
324
+ * dies instantly and would otherwise spawn-loop forever.
325
+ */
326
+ spawnWorker() {
327
+ const w = new ClaudeWorker(this.opts);
328
+ w.onDeath = () => {
329
+ if (this.disposed)
330
+ return;
331
+ const wasHealthy = w.turns > 0 || Date.now() - w.bornAt > 60_000;
332
+ if (!wasHealthy)
333
+ return;
334
+ setTimeout(() => this.ensurePool(), 250);
335
+ };
336
+ return w;
337
+ }
338
+ async complete(prompt, { signal, onChunk, diag } = {}) {
339
+ if (this.disposed)
340
+ throw new Error("backend disposed");
341
+ signal?.throwIfAborted();
342
+ // A pooled worker can die between turns (the CLI process exits on its own
343
+ // in some versions). Retry once on a guaranteed-fresh worker before giving
344
+ // up — but never after the caller aborted or emitted partial ghost text.
345
+ let emitted = false;
346
+ const guard = (t) => {
347
+ if (!signal?.aborted) {
348
+ emitted = true;
349
+ onChunk?.(t);
350
+ }
351
+ };
352
+ try {
353
+ return await this.attempt(prompt, signal, guard, false, diag);
354
+ }
355
+ catch (err) {
356
+ if (this.disposed || signal?.aborted || emitted || !isWorkerDeath(err))
357
+ throw err;
358
+ if (diag)
359
+ diag.retried = true;
360
+ return await this.attempt(prompt, signal, guard, true, diag);
361
+ }
362
+ }
363
+ async attempt(prompt, signal, guard, forceFresh, diag) {
364
+ const tAcquire = performance.now();
365
+ this.ensurePool();
366
+ if (diag) {
367
+ diag.transport = this.opts.transport.kind;
368
+ diag.poolSize = this.pool.length;
369
+ diag.poolBusy = this.pool.filter((w) => w.busy && !w.dead).length;
370
+ }
371
+ // Prefer a WARMED idle worker; only fall back to a still-warming one
372
+ // (and only then to a cold spawn). Landing on a booting worker costs the
373
+ // whole warmup (~5s over ssh) even when a warm sibling is idle.
374
+ let worker = forceFresh
375
+ ? undefined
376
+ : this.pool.find((w) => !w.busy && !w.dead && w.warmedDone) ??
377
+ this.pool.find((w) => !w.busy && !w.dead);
378
+ if (!worker) {
379
+ worker = this.spawnWorker();
380
+ this.pool.push(worker);
381
+ if (diag)
382
+ diag.coldSpawn = true;
383
+ console.log(`[autocomplete] cold worker spawn (${this.opts.transport.kind}): pool ${this.pool.length - 1} all busy/dead`);
384
+ }
385
+ worker.busy = true;
386
+ if (diag) {
387
+ diag.workerTurn = worker.turns;
388
+ diag.workerHistoryChars = worker.sentChars;
389
+ }
390
+ // Always wait for the warmup turn: dispatching while it is in flight
391
+ // would steal its pending handler and resolve with the warmup's output.
392
+ await worker.warmed;
393
+ if (diag)
394
+ diag.workerWaitMs = Math.round(performance.now() - tAcquire);
395
+ try {
396
+ const full = await worker.runTurn(prompt, guard, signal);
397
+ signal?.throwIfAborted();
398
+ return full;
399
+ }
400
+ finally {
401
+ worker.turns += 1;
402
+ worker.sentChars += prompt.length;
403
+ worker.busy = false;
404
+ if (worker.turns >= this.opts.maxTurnsPerWorker ||
405
+ worker.sentChars >= this.opts.maxHistoryCharsPerWorker ||
406
+ worker.dead) {
407
+ const recycled = worker;
408
+ recycled.kill();
409
+ // Prune only THIS worker's transcript dir (matched by its own cwd
410
+ // token) so a concurrently-active worker's dir is never disturbed.
411
+ // Bounds growth over a long-lived server, not just at shutdown.
412
+ this.pruneTranscripts(recycled.cwdToken);
413
+ if (this.opts.transport.kind === "local") {
414
+ try {
415
+ rmSync(recycled.cwd, { recursive: true, force: true });
416
+ }
417
+ catch {
418
+ /* best-effort */
419
+ }
420
+ } // ssh worker dirs are remote — cleaned by recycled.kill()
421
+ this.ensurePool(); // warm replacement spawns in the background
422
+ }
423
+ }
424
+ }
425
+ dispose() {
426
+ this.disposed = true;
427
+ for (const w of this.pool)
428
+ w.kill();
429
+ this.pool = [];
430
+ this.pruneTranscripts();
431
+ if (this.opts.transport.kind === "ssh") {
432
+ // Tear down the shared ControlMaster connection.
433
+ if (this.opts.transport.controlPath) {
434
+ try {
435
+ spawn("ssh", ["-O", "exit", "-o", `ControlPath=${this.opts.transport.controlPath}`,
436
+ `${this.opts.transport.user}@${this.opts.transport.host}`], { stdio: "ignore" }).unref();
437
+ }
438
+ catch { /* best-effort */ }
439
+ }
440
+ return;
441
+ }
442
+ try {
443
+ rmSync(this.opts.workspaceDir, { recursive: true, force: true });
444
+ }
445
+ catch {
446
+ /* best-effort */
447
+ }
448
+ }
449
+ }
@@ -0,0 +1,45 @@
1
+ import type { CompletionBackend } from "../types.js";
2
+ import { type Transport } from "../transport.js";
3
+ export interface CodexBackendOptions {
4
+ /**
5
+ * Model. Default "gpt-5.6-luna" — the fast model available with
6
+ * ChatGPT-subscription auth (measured ~2× faster than the retired
7
+ * gpt-5.4-mini on warm autocomplete requests). The *-codex-mini
8
+ * variants are API-key-only.
9
+ */
10
+ model?: string;
11
+ /** Reasoning effort. Default "low" ("minimal" is rejected by codex's built-in tools). */
12
+ reasoningEffort?: string;
13
+ /**
14
+ * CODEX_HOME containing only auth.json. Strongly recommended: skips the
15
+ * global config's MCP servers/plugins/hooks (measured ~2s/request saved).
16
+ * Prepare with: mkdir -p <dir> && cp ~/.codex/auth.json <dir>/
17
+ */
18
+ codexHome?: string;
19
+ /** Per-request timeout. Default 60_000 ms. */
20
+ timeoutMs?: number;
21
+ /** Path to the codex binary. Default "codex". */
22
+ binary?: string;
23
+ /** Working directory. Default a temp dir. */
24
+ workspaceDir?: string;
25
+ /** Where codex runs: locally (default) or over ssh on the user's machine.
26
+ * Over ssh, codexHome is ignored (the user's own ~/.codex/auth.json is used;
27
+ * --ephemeral already keeps completions trace-free). */
28
+ transport?: Transport;
29
+ }
30
+ /**
31
+ * One-shot `codex exec --json` per completion (subscription auth). Codex has
32
+ * no incremental token stream in exec mode, so results arrive buffered.
33
+ */
34
+ export declare class CodexBackend implements CompletionBackend {
35
+ readonly name = "codex";
36
+ private opts;
37
+ constructor(options?: CodexBackendOptions);
38
+ complete(prompt: string, { signal, onChunk }?: {
39
+ signal?: AbortSignal;
40
+ onChunk?: (t: string) => void;
41
+ }): Promise<string>;
42
+ dispose(): void;
43
+ }
44
+ /** Find the agent message in codex --json JSONL output (shape varies by version). */
45
+ export declare function extractAgentMessage(jsonl: string): string;
@@ -0,0 +1,108 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdirSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { planSpawn } from "../transport.js";
6
+ /**
7
+ * One-shot `codex exec --json` per completion (subscription auth). Codex has
8
+ * no incremental token stream in exec mode, so results arrive buffered.
9
+ */
10
+ export class CodexBackend {
11
+ name = "codex";
12
+ opts;
13
+ constructor(options = {}) {
14
+ this.opts = {
15
+ model: options.model ?? "gpt-5.6-luna",
16
+ reasoningEffort: options.reasoningEffort ?? "low",
17
+ codexHome: options.codexHome,
18
+ timeoutMs: options.timeoutMs ?? 60_000,
19
+ binary: options.binary ?? "codex",
20
+ workspaceDir: options.workspaceDir ?? join(tmpdir(), "nebula-autocomplete-ws"),
21
+ transport: options.transport ?? { kind: "local" },
22
+ };
23
+ if (this.opts.transport.kind === "local")
24
+ mkdirSync(this.opts.workspaceDir, { recursive: true });
25
+ }
26
+ complete(prompt, { signal, onChunk } = {}) {
27
+ return new Promise((resolve, reject) => {
28
+ if (signal?.aborted)
29
+ return reject(signal.reason ?? new Error("aborted"));
30
+ const argv = [
31
+ "exec", "--json",
32
+ "--skip-git-repo-check",
33
+ "--sandbox", "read-only",
34
+ "--color", "never",
35
+ // Completions are throwaway: don't persist rollout/session files or
36
+ // prompt history — at autocomplete frequency they pollute CODEX_HOME.
37
+ "--ephemeral",
38
+ "-c", 'history.persistence="none"',
39
+ // Also skip the curated-plugins sync, which otherwise clones tens of
40
+ // MB into CODEX_HOME/.tmp on every exec.
41
+ "-c", "features.plugins=false",
42
+ "-m", this.opts.model,
43
+ "-c", `model_reasoning_effort="${this.opts.reasoningEffort}"`,
44
+ prompt,
45
+ ];
46
+ // Local: honor codexHome (skips global config's MCP/plugins). Over ssh,
47
+ // use the user's own ~/.codex on their machine (auth lives there).
48
+ const envOverrides = this.opts.transport.kind === "local" && this.opts.codexHome
49
+ ? { CODEX_HOME: this.opts.codexHome }
50
+ : {};
51
+ const cwd = this.opts.transport.kind === "ssh" ? "/tmp/nebula-autocomplete-ws" : this.opts.workspaceDir;
52
+ const plan = planSpawn(this.opts.transport, this.opts.binary, argv, envOverrides, cwd);
53
+ const proc = spawn(plan.command, plan.args, { ...plan.options, stdio: ["ignore", "pipe", "pipe"] });
54
+ let out = "";
55
+ let err = "";
56
+ proc.stdout.on("data", (d) => (out += d.toString()));
57
+ proc.stderr.on("data", (d) => (err += d.toString()));
58
+ const timer = setTimeout(() => {
59
+ proc.kill();
60
+ reject(new Error("codex timed out"));
61
+ }, this.opts.timeoutMs);
62
+ const onAbort = () => {
63
+ proc.kill();
64
+ reject(signal?.reason ?? new Error("aborted"));
65
+ };
66
+ signal?.addEventListener("abort", onAbort, { once: true });
67
+ proc.on("error", (e) => {
68
+ clearTimeout(timer);
69
+ reject(e);
70
+ });
71
+ proc.on("exit", (code) => {
72
+ clearTimeout(timer);
73
+ signal?.removeEventListener("abort", onAbort);
74
+ if (signal?.aborted)
75
+ return;
76
+ if (code !== 0) {
77
+ return reject(new Error(`codex rc=${code}: ${err.slice(-300)}`));
78
+ }
79
+ const text = extractAgentMessage(out);
80
+ if (text)
81
+ onChunk?.(text);
82
+ resolve(text);
83
+ });
84
+ });
85
+ }
86
+ dispose() {
87
+ /* one-shot processes; nothing persistent to release */
88
+ }
89
+ }
90
+ /** Find the agent message in codex --json JSONL output (shape varies by version). */
91
+ export function extractAgentMessage(jsonl) {
92
+ let text = "";
93
+ for (const line of jsonl.split("\n")) {
94
+ let ev;
95
+ try {
96
+ ev = JSON.parse(line);
97
+ }
98
+ catch {
99
+ continue;
100
+ }
101
+ const item = ev.item ?? ev.msg ?? ev;
102
+ const kind = String(item.item_type ?? item.type ?? "");
103
+ if (kind.includes("agent_message") && typeof item.text === "string") {
104
+ text = item.text;
105
+ }
106
+ }
107
+ return text;
108
+ }
@@ -0,0 +1,16 @@
1
+ import type { CompletionRequest, CompletionResult } from "../types.js";
2
+ export interface FetchCompletionOptions {
3
+ signal?: AbortSignal;
4
+ onChunk?: (text: string) => void;
5
+ }
6
+ export type CompletionFetcher = (req: CompletionRequest, opts?: FetchCompletionOptions) => Promise<CompletionResult>;
7
+ /**
8
+ * Browser-side client for the SSE endpoint registered by
9
+ * registerAutocompleteRoute. Streams chunks via onChunk and resolves with the
10
+ * final CompletionResult.
11
+ */
12
+ export interface CompletionFetcherOptions {
13
+ /** Extra headers (e.g. auth) merged into every request. */
14
+ headers?: Record<string, string> | (() => Record<string, string>);
15
+ }
16
+ export declare function createCompletionFetcher(endpoint: string, options?: CompletionFetcherOptions): CompletionFetcher;
@@ -0,0 +1,41 @@
1
+ export function createCompletionFetcher(endpoint, options = {}) {
2
+ return async (req, { signal, onChunk } = {}) => {
3
+ const extra = typeof options.headers === "function" ? options.headers() : (options.headers ?? {});
4
+ const resp = await fetch(endpoint, {
5
+ method: "POST",
6
+ headers: { "content-type": "application/json", ...extra },
7
+ body: JSON.stringify(req),
8
+ signal,
9
+ });
10
+ if (!resp.ok || !resp.body) {
11
+ throw new Error(`autocomplete request failed: HTTP ${resp.status}`);
12
+ }
13
+ const reader = resp.body.getReader();
14
+ const decoder = new TextDecoder();
15
+ let buf = "";
16
+ let done = null;
17
+ for (;;) {
18
+ const { done: eof, value } = await reader.read();
19
+ if (eof)
20
+ break;
21
+ buf += decoder.decode(value, { stream: true });
22
+ let i;
23
+ while ((i = buf.indexOf("\n\n")) !== -1) {
24
+ const line = buf.slice(0, i).replace(/^data: /, "");
25
+ buf = buf.slice(i + 2);
26
+ if (!line.trim())
27
+ continue;
28
+ const ev = JSON.parse(line);
29
+ if (ev.type === "chunk")
30
+ onChunk?.(ev.text);
31
+ else if (ev.type === "done")
32
+ done = ev;
33
+ else if (ev.type === "error")
34
+ throw new Error(ev.message);
35
+ }
36
+ }
37
+ if (!done)
38
+ throw new Error("stream ended without a done event");
39
+ return done;
40
+ };
41
+ }