nebula-notebook 0.2.0 → 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 (100) hide show
  1. package/README.md +35 -20
  2. package/dist/assets/{errorwidget-C4r2j2DQ.js → errorwidget-Wy5ajPiM.js} +1 -1
  3. package/dist/assets/{index-Day3QcNs.js → index-B_Bs5koo.js} +1 -1
  4. package/dist/assets/index-DK4nS74r.js +829 -0
  5. package/dist/assets/{index-D5w21_Z8.js → index-DeQN03nW.js} +1 -1
  6. package/dist/assets/index-DzjpmUak.css +32 -0
  7. package/dist/assets/{index-CsHoPQy-.js → index-g6MjT-VG.js} +1 -1
  8. package/dist/assets/{services-shim-D6p_A67v.js → services-shim-BirHVsih.js} +1 -1
  9. package/dist/index.html +2 -2
  10. package/node-server/dist/auth/auth-middleware.js +22 -4
  11. package/node-server/dist/auth/auth-service.js +37 -7
  12. package/node-server/dist/cluster/client-registration.d.ts +12 -1
  13. package/node-server/dist/cluster/client-registration.js +35 -2
  14. package/node-server/dist/cluster/kernel-proxy.js +19 -3
  15. package/node-server/dist/cluster/server-registry.d.ts +9 -0
  16. package/node-server/dist/cluster/server-registry.js +26 -0
  17. package/node-server/dist/discovery/conda-locations.d.ts +73 -0
  18. package/node-server/dist/discovery/conda-locations.js +427 -0
  19. package/node-server/dist/discovery/discovery-service.d.ts +73 -4
  20. package/node-server/dist/discovery/discovery-service.js +211 -127
  21. package/node-server/dist/discovery/types.d.ts +0 -1
  22. package/node-server/dist/discovery/types.js +1 -2
  23. package/node-server/dist/environment.d.ts +12 -0
  24. package/node-server/dist/environment.js +104 -0
  25. package/node-server/dist/fs/fs-service.d.ts +31 -1
  26. package/node-server/dist/fs/fs-service.js +160 -28
  27. package/node-server/dist/fs/types.d.ts +9 -0
  28. package/node-server/dist/fs/types.js +8 -0
  29. package/node-server/dist/index.js +71 -4
  30. package/node-server/dist/kernel/kernel-service.d.ts +17 -0
  31. package/node-server/dist/kernel/kernel-service.js +72 -4
  32. package/node-server/dist/kernel/kernelspec.d.ts +27 -0
  33. package/node-server/dist/kernel/kernelspec.js +75 -4
  34. package/node-server/dist/notebook/headless-handler.d.ts +10 -0
  35. package/node-server/dist/notebook/headless-handler.js +55 -5
  36. package/node-server/dist/notebook/undoRedoManager.d.ts +8 -0
  37. package/node-server/dist/notebook/undoRedoManager.js +39 -16
  38. package/node-server/dist/private-tmp.d.ts +14 -0
  39. package/node-server/dist/private-tmp.js +70 -0
  40. package/node-server/dist/routes/autocomplete.d.ts +19 -0
  41. package/node-server/dist/routes/autocomplete.js +294 -0
  42. package/node-server/dist/routes/fs.js +21 -4
  43. package/node-server/dist/routes/kernel.js +45 -2
  44. package/node-server/dist/routes/notebook.js +8 -2
  45. package/node-server/dist/routes/python.d.ts +1 -2
  46. package/node-server/dist/routes/python.js +139 -3
  47. package/node-server/dist/scheduler/allocation-service.d.ts +20 -0
  48. package/node-server/dist/scheduler/allocation-service.js +124 -8
  49. package/node-server/dist/scheduler/mock-scheduler.js +7 -3
  50. package/node-server/dist/scheduler/slurm-scheduler.js +21 -8
  51. package/node-server/dist/scheduler/types.d.ts +3 -2
  52. package/node-server/dist/terminal/agent-registry.d.ts +87 -0
  53. package/node-server/dist/terminal/agent-registry.js +246 -0
  54. package/node-server/dist/terminal/binding-store.d.ts +57 -0
  55. package/node-server/dist/terminal/binding-store.js +0 -0
  56. package/node-server/dist/terminal/pty-manager.d.ts +23 -6
  57. package/node-server/dist/terminal/pty-manager.js +130 -19
  58. package/node-server/dist/terminal/server.js +112 -21
  59. package/node-server/dist/terminal/types.d.ts +2 -0
  60. package/node-server/package.json +1 -0
  61. package/node_modules/nebula-autocomplete/README.md +145 -0
  62. package/node_modules/nebula-autocomplete/dist/backends/claude.d.ts +69 -0
  63. package/node_modules/nebula-autocomplete/dist/backends/claude.js +449 -0
  64. package/node_modules/nebula-autocomplete/dist/backends/codex.d.ts +45 -0
  65. package/node_modules/nebula-autocomplete/dist/backends/codex.js +108 -0
  66. package/node_modules/nebula-autocomplete/dist/client/fetcher.d.ts +16 -0
  67. package/node_modules/nebula-autocomplete/dist/client/fetcher.js +41 -0
  68. package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.d.ts +43 -0
  69. package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.js +175 -0
  70. package/node_modules/nebula-autocomplete/dist/core/engine.d.ts +17 -0
  71. package/node_modules/nebula-autocomplete/dist/core/engine.js +135 -0
  72. package/node_modules/nebula-autocomplete/dist/core/lru.d.ts +7 -0
  73. package/node_modules/nebula-autocomplete/dist/core/lru.js +26 -0
  74. package/node_modules/nebula-autocomplete/dist/core/prompt.d.ts +12 -0
  75. package/node_modules/nebula-autocomplete/dist/core/prompt.js +0 -0
  76. package/node_modules/nebula-autocomplete/dist/core/text.d.ts +53 -0
  77. package/node_modules/nebula-autocomplete/dist/core/text.js +169 -0
  78. package/node_modules/nebula-autocomplete/dist/index.d.ts +7 -0
  79. package/node_modules/nebula-autocomplete/dist/index.js +5 -0
  80. package/node_modules/nebula-autocomplete/dist/server/fastify.d.ts +40 -0
  81. package/node_modules/nebula-autocomplete/dist/server/fastify.js +52 -0
  82. package/node_modules/nebula-autocomplete/dist/transport.d.ts +55 -0
  83. package/node_modules/nebula-autocomplete/dist/transport.js +66 -0
  84. package/node_modules/nebula-autocomplete/dist/types.d.ts +103 -0
  85. package/node_modules/nebula-autocomplete/dist/types.js +1 -0
  86. package/node_modules/nebula-autocomplete/package.json +58 -0
  87. package/node_modules/nebula-autocomplete/server.d.ts +3 -0
  88. package/node_modules/nebula-autocomplete/server.js +3 -0
  89. package/package.json +17 -5
  90. package/scripts/postinstall.cjs +25 -5
  91. package/dist/assets/index-7-YBurka.js +0 -716
  92. package/dist/assets/index-CFBUnxSZ.css +0 -32
  93. package/node-server/dist/llm/index.d.ts +0 -5
  94. package/node-server/dist/llm/index.js +0 -21
  95. package/node-server/dist/llm/llm-service.d.ts +0 -77
  96. package/node-server/dist/llm/llm-service.js +0 -454
  97. package/node-server/dist/llm/types.d.ts +0 -40
  98. package/node-server/dist/llm/types.js +0 -15
  99. package/node-server/dist/routes/llm.d.ts +0 -8
  100. package/node-server/dist/routes/llm.js +0 -105
@@ -0,0 +1,145 @@
1
+ # nebula-autocomplete
2
+
3
+ LLM code autocomplete for notebook editors, backed by **Claude Code CLI** or
4
+ **Codex CLI** subscription auth (no API key). Three entry points:
5
+
6
+ | Import | What it is |
7
+ |---|---|
8
+ | `nebula-autocomplete` | `AutocompleteEngine` + `ClaudeBackend` / `CodexBackend` (Node) |
9
+ | `nebula-autocomplete/server` | `registerAutocompleteRoute(fastify, engine)` — SSE endpoint |
10
+ | `nebula-autocomplete/client` | `createCompletionFetcher(url)` — browser SSE client |
11
+ | `nebula-autocomplete/codemirror` | `ghostText(fetcher)` — CodeMirror 6 inline ghost text |
12
+
13
+ > **Licensing:** subscription auth is for personal use. Anthropic/OpenAI
14
+ > consumer terms prohibit routing other users through your subscription or
15
+ > offering subscription login in a distributed app. To ship this to others,
16
+ > implement a `CompletionBackend` that calls the provider API with a key —
17
+ > the rest of the stack is unchanged.
18
+
19
+ ## Measured latency (2026-07, M-series Mac)
20
+
21
+ | Backend | Model | Warm TTFB | Notes |
22
+ |---|---|---|---|
23
+ | `ClaudeBackend` | haiku 4.5 | ~1.4–2.1s | token-streamed; warm process pool |
24
+ | `CodexBackend` | gpt-5.6-luna, low effort | ~2.2–2.7s | buffered; one-shot exec |
25
+
26
+ Cache hits return in 0ms. Claude's harness costs ~24k input tokens per
27
+ completion, codex ~12.6k — that overhead is inherent to CLI backends and is
28
+ why a raw-API backend (~0.6–1s) is the upgrade path for keystroke-grade UX.
29
+
30
+ ## Quick start (server side)
31
+
32
+ ```ts
33
+ import { AutocompleteEngine, ClaudeBackend } from "nebula-autocomplete";
34
+ import { registerAutocompleteRoute } from "nebula-autocomplete/server";
35
+
36
+ const engine = new AutocompleteEngine({
37
+ backend: new ClaudeBackend(), // or new CodexBackend({ codexHome })
38
+ cacheSize: 128,
39
+ contextBudget: 6000, // chars of cross-cell context
40
+ });
41
+
42
+ // inside your Fastify route registration (prefix "/api"):
43
+ registerAutocompleteRoute(fastify, engine); // POST /api/autocomplete (SSE)
44
+
45
+ process.on("exit", () => engine.dispose());
46
+ ```
47
+
48
+ `CodexBackend` tip: create a clean `CODEX_HOME` containing only `auth.json`
49
+ (`mkdir -p .codex-home && cp ~/.codex/auth.json .codex-home/`) and pass it as
50
+ `codexHome` — it skips global MCP servers/plugins/hooks (~2s/request faster).
51
+ `gpt-5.6-luna` is the fast model that works with ChatGPT-subscription auth;
52
+ `*-codex-mini` variants are API-key-only.
53
+
54
+ ## Quick start (client side, CodeMirror 6)
55
+
56
+ ```ts
57
+ import { createCompletionFetcher } from "nebula-autocomplete/client";
58
+ import { ghostText } from "nebula-autocomplete/codemirror";
59
+
60
+ const fetchCompletion = createCompletionFetcher("/api/autocomplete");
61
+
62
+ const extension = ghostText(
63
+ ({ prefix, suffix }, { signal, onChunk }) =>
64
+ fetchCompletion(
65
+ {
66
+ prefix,
67
+ suffix,
68
+ language: "python",
69
+ cells: allCellsRef.current ?? [], // cross-cell context
70
+ activeCellIndex,
71
+ sessionKey: cellId, // supersedes stale requests
72
+ },
73
+ { signal, onChunk },
74
+ ).then((r) => r.text),
75
+ { debounceMs: 400 },
76
+ );
77
+ // add `extension` to the editor's extensions array
78
+ ```
79
+
80
+ Tab accepts the suggestion, Escape dismisses, any edit or cursor move
81
+ invalidates it. Ghost text renders via a `.cm-ghost-text` span (style it in
82
+ your theme if the default 0.45 opacity doesn't fit).
83
+
84
+ ## Integrating into nebula-notebook
85
+
86
+ This package now lives inside the nebula-notebook repo at
87
+ `packages/autocomplete` and is wired up already:
88
+
89
+ 1. **Dependency** (in-repo `file:` link; bundled into the published tarball via
90
+ `bundledDependencies`):
91
+ - root `package.json`: `"nebula-autocomplete": "file:packages/autocomplete"`
92
+ - `node-server/package.json`: `"nebula-autocomplete": "file:../packages/autocomplete"`
93
+ `dist/` is built automatically by this package's `prepare` script on
94
+ `npm install` (see `scripts/postinstall.cjs`).
95
+ 2. **Server**: in `node-server/src/index.ts` where routes are registered under
96
+ `/api` (~line 302), create one engine at startup and call
97
+ `registerAutocompleteRoute(app, engine)`. Dispose it on shutdown.
98
+ 3. **Client**: in `components/CodeEditor.tsx`, add the `ghostText(...)`
99
+ extension to the extensions `useMemo` (~line 725). The existing
100
+ kernel/static dropdown completion (`createCombinedCompletionSource`, line
101
+ 533) stays as-is — ghost text is complementary, not a replacement. Cells
102
+ context is already available via the `allCellsRef` prop (line 51).
103
+ 4. **Dev proxy**: nothing to add — Vite already proxies `/api` to the node
104
+ server (`vite.config.ts:19`).
105
+
106
+ ## API sketch
107
+
108
+ - `engine.complete(req, { signal, onChunk }) → Promise<CompletionResult>` —
109
+ builds the prompt (instruction + nearest-cells context within budget +
110
+ `<CURSOR>` marker), checks the LRU cache, and calls the backend. Requests
111
+ sharing a `sessionKey` supersede each other. Output is fence-stripped and
112
+ prefix-echo-trimmed.
113
+ - `CompletionBackend` — implement `{ name, complete(prompt, {signal, onChunk}), dispose() }`
114
+ to add a backend (e.g. Anthropic API for production).
115
+
116
+ ## Trajectory hygiene
117
+
118
+ Completions must not spam CLI history/trajectory stores. Verified behavior:
119
+
120
+ - **claude**: print mode + stream-json + `--tools ""` was observed to persist
121
+ nothing on macOS (claude 2.1.201), but Claude Code **can** write
122
+ `~/.claude/projects/<munged-cwd>/*.jsonl` session transcripts on other
123
+ versions/platforms — so we don't rely on that. Each worker runs in its own
124
+ UUID cwd; the backend deletes the matching `~/.claude/projects/*` transcript
125
+ dir on worker recycle (per-worker, so it never touches an active worker) and
126
+ the whole set on `dispose()`. Matching is by UUID token via
127
+ `transcriptDirMatchesToken`, which is munge-algorithm-independent and cannot
128
+ collide with a real project. Respects `CLAUDE_CONFIG_DIR`. The throwaway
129
+ `workspaceDir` (also removed on dispose) is a second guard.
130
+ - **codex**: `--ephemeral` (no session/rollout files),
131
+ `history.persistence="none"` (no prompt history), and
132
+ `features.plugins=false` (skips the curated-plugins sync, which otherwise
133
+ clones tens of MB into `CODEX_HOME/.tmp` on **every** exec). Verified: zero
134
+ files and stable directory size across repeated completions. Codex still
135
+ maintains a few small bounded sqlite files in `CODEX_HOME`.
136
+
137
+ ## Development
138
+
139
+ ```sh
140
+ npm install
141
+ npm run build # tsc → dist/
142
+ npm test # vitest unit tests (no CLI calls)
143
+ npm run smoke # live end-to-end vs claude (uses your subscription)
144
+ NEBULA_CODEX_HOME=path/to/.codex-home npm run smoke codex
145
+ ```
@@ -0,0 +1,69 @@
1
+ import type { CompletionBackend } from "../types.js";
2
+ import { type Transport } from "../transport.js";
3
+ export interface ClaudeBackendOptions {
4
+ /** Model passed to --model. Default "haiku" (fastest tier). */
5
+ model?: string;
6
+ /** Warm worker processes. Default 2. */
7
+ poolSize?: number;
8
+ /** Turns before a worker is recycled (stream-json turns share history). Default 8. */
9
+ maxTurnsPerWorker?: number;
10
+ /** Accumulated prompt chars before a worker is recycled. History size — not
11
+ * turn count — is what drags TTFB, so this is the primary recycle driver.
12
+ * Default 18_000 (~3 big-notebook prompts). */
13
+ maxHistoryCharsPerWorker?: number;
14
+ /** Per-turn timeout. Default 45_000 ms. */
15
+ turnTimeoutMs?: number;
16
+ /** Working directory for workers — keep it empty so no CLAUDE.md loads. */
17
+ workspaceDir?: string;
18
+ /** Path to the claude binary. Default "claude". */
19
+ binary?: string;
20
+ /** Where the claude process runs: locally (default) or over ssh on the user's
21
+ * machine (remote Nebula + local agent). Default { kind: "local" }. */
22
+ transport?: Transport;
23
+ /** Thinking-token budget. Default 0: thinking never streams as ghost text
24
+ * (observed burning a whole turn invisibly), but a budget can improve
25
+ * tricky completions — surfaced as an Advanced setting. */
26
+ maxThinkingTokens?: number;
27
+ }
28
+ /**
29
+ * Persistent warm pool of `claude` CLI processes (subscription auth) in
30
+ * stream-json mode. Workers are recycled after maxTurnsPerWorker completions
31
+ * so conversation history never bloats the context.
32
+ */
33
+ export declare class ClaudeBackend implements CompletionBackend {
34
+ readonly name = "claude";
35
+ private pool;
36
+ private opts;
37
+ private disposed;
38
+ private readonly wsToken;
39
+ constructor(options?: ClaudeBackendOptions);
40
+ /**
41
+ * Delete any Claude Code session-transcript directories that belong to this
42
+ * backend. Claude writes `~/.claude/projects/<munged-cwd>/*.jsonl` on some
43
+ * versions/platforms; the munged name preserves alphanumerics, so our
44
+ * wsToken (a UUID) always survives. We match by comparing alphanumeric-only
45
+ * forms, which is munge-algorithm-independent and cannot collide with a real
46
+ * project path. Best-effort: never throws into the caller.
47
+ */
48
+ private pruneTranscripts;
49
+ private ensurePool;
50
+ /**
51
+ * Spawn a worker wired for background self-healing: the claude CLI exits on
52
+ * its own after long idle, and replacing it lazily (at the next request)
53
+ * put the whole cold start — process boot + ssh reconnect + warmup turn —
54
+ * on the user's first post-idle completion (multi-second "autocomplete is
55
+ * dead after I come back" lag). Respawning the moment a worker dies keeps
56
+ * the pool warm through idle, so the first keystroke after a break pays the
57
+ * same ~1.5s as any other. Crash-loop guard: only self-respawn for workers
58
+ * that were healthy (survived 60s or completed a turn) — a broken binary
59
+ * dies instantly and would otherwise spawn-loop forever.
60
+ */
61
+ private spawnWorker;
62
+ complete(prompt: string, { signal, onChunk, diag }?: {
63
+ signal?: AbortSignal;
64
+ onChunk?: (t: string) => void;
65
+ diag?: import("../types.js").CompletionDiag;
66
+ }): Promise<string>;
67
+ private attempt;
68
+ dispose(): void;
69
+ }
@@ -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
+ }