nebula-notebook 0.2.0 → 0.2.2
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 +35 -20
- package/dist/assets/{errorwidget-C4r2j2DQ.js → errorwidget-Wy5ajPiM.js} +1 -1
- package/dist/assets/{index-Day3QcNs.js → index-B_Bs5koo.js} +1 -1
- package/dist/assets/index-DK4nS74r.js +829 -0
- package/dist/assets/{index-D5w21_Z8.js → index-DeQN03nW.js} +1 -1
- package/dist/assets/index-DzjpmUak.css +32 -0
- package/dist/assets/{index-CsHoPQy-.js → index-g6MjT-VG.js} +1 -1
- package/dist/assets/{services-shim-D6p_A67v.js → services-shim-BirHVsih.js} +1 -1
- package/dist/index.html +2 -2
- package/node-server/dist/auth/auth-middleware.js +22 -4
- package/node-server/dist/auth/auth-service.js +37 -7
- package/node-server/dist/cluster/client-registration.d.ts +12 -1
- package/node-server/dist/cluster/client-registration.js +35 -2
- package/node-server/dist/cluster/kernel-proxy.js +19 -3
- package/node-server/dist/cluster/server-registry.d.ts +9 -0
- package/node-server/dist/cluster/server-registry.js +26 -0
- package/node-server/dist/discovery/conda-locations.d.ts +73 -0
- package/node-server/dist/discovery/conda-locations.js +427 -0
- package/node-server/dist/discovery/discovery-service.d.ts +73 -4
- package/node-server/dist/discovery/discovery-service.js +211 -127
- package/node-server/dist/discovery/types.d.ts +0 -1
- package/node-server/dist/discovery/types.js +1 -2
- package/node-server/dist/environment.d.ts +12 -0
- package/node-server/dist/environment.js +104 -0
- package/node-server/dist/fs/fs-service.d.ts +31 -1
- package/node-server/dist/fs/fs-service.js +160 -28
- package/node-server/dist/fs/types.d.ts +9 -0
- package/node-server/dist/fs/types.js +8 -0
- package/node-server/dist/index.js +71 -4
- package/node-server/dist/kernel/kernel-service.d.ts +17 -0
- package/node-server/dist/kernel/kernel-service.js +72 -4
- package/node-server/dist/kernel/kernelspec.d.ts +27 -0
- package/node-server/dist/kernel/kernelspec.js +75 -4
- package/node-server/dist/notebook/headless-handler.d.ts +10 -0
- package/node-server/dist/notebook/headless-handler.js +55 -5
- package/node-server/dist/notebook/undoRedoManager.d.ts +8 -0
- package/node-server/dist/notebook/undoRedoManager.js +39 -16
- package/node-server/dist/private-tmp.d.ts +14 -0
- package/node-server/dist/private-tmp.js +70 -0
- package/node-server/dist/routes/autocomplete.d.ts +19 -0
- package/node-server/dist/routes/autocomplete.js +294 -0
- package/node-server/dist/routes/fs.js +21 -4
- package/node-server/dist/routes/kernel.js +45 -2
- package/node-server/dist/routes/notebook.js +8 -2
- package/node-server/dist/routes/python.d.ts +1 -2
- package/node-server/dist/routes/python.js +139 -3
- package/node-server/dist/scheduler/allocation-service.d.ts +20 -0
- package/node-server/dist/scheduler/allocation-service.js +124 -8
- package/node-server/dist/scheduler/mock-scheduler.js +7 -3
- package/node-server/dist/scheduler/slurm-scheduler.js +21 -8
- package/node-server/dist/scheduler/types.d.ts +3 -2
- package/node-server/dist/terminal/agent-registry.d.ts +87 -0
- package/node-server/dist/terminal/agent-registry.js +246 -0
- package/node-server/dist/terminal/binding-store.d.ts +57 -0
- package/node-server/dist/terminal/binding-store.js +0 -0
- package/node-server/dist/terminal/pty-manager.d.ts +23 -6
- package/node-server/dist/terminal/pty-manager.js +130 -19
- package/node-server/dist/terminal/server.js +112 -21
- package/node-server/dist/terminal/types.d.ts +2 -0
- package/node-server/package.json +1 -0
- package/node_modules/nebula-autocomplete/README.md +145 -0
- package/node_modules/nebula-autocomplete/dist/backends/claude.d.ts +69 -0
- package/node_modules/nebula-autocomplete/dist/backends/claude.js +449 -0
- package/node_modules/nebula-autocomplete/dist/backends/codex.d.ts +45 -0
- package/node_modules/nebula-autocomplete/dist/backends/codex.js +108 -0
- package/node_modules/nebula-autocomplete/dist/client/fetcher.d.ts +16 -0
- package/node_modules/nebula-autocomplete/dist/client/fetcher.js +41 -0
- package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.d.ts +43 -0
- package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.js +175 -0
- package/node_modules/nebula-autocomplete/dist/core/engine.d.ts +17 -0
- package/node_modules/nebula-autocomplete/dist/core/engine.js +135 -0
- package/node_modules/nebula-autocomplete/dist/core/lru.d.ts +7 -0
- package/node_modules/nebula-autocomplete/dist/core/lru.js +26 -0
- package/node_modules/nebula-autocomplete/dist/core/prompt.d.ts +12 -0
- package/node_modules/nebula-autocomplete/dist/core/prompt.js +0 -0
- package/node_modules/nebula-autocomplete/dist/core/text.d.ts +53 -0
- package/node_modules/nebula-autocomplete/dist/core/text.js +169 -0
- package/node_modules/nebula-autocomplete/dist/index.d.ts +7 -0
- package/node_modules/nebula-autocomplete/dist/index.js +5 -0
- package/node_modules/nebula-autocomplete/dist/server/fastify.d.ts +40 -0
- package/node_modules/nebula-autocomplete/dist/server/fastify.js +52 -0
- package/node_modules/nebula-autocomplete/dist/transport.d.ts +55 -0
- package/node_modules/nebula-autocomplete/dist/transport.js +66 -0
- package/node_modules/nebula-autocomplete/dist/types.d.ts +103 -0
- package/node_modules/nebula-autocomplete/dist/types.js +1 -0
- package/node_modules/nebula-autocomplete/package.json +58 -0
- package/node_modules/nebula-autocomplete/server.d.ts +3 -0
- package/node_modules/nebula-autocomplete/server.js +3 -0
- package/package.json +17 -5
- package/scripts/postinstall.cjs +25 -5
- package/dist/assets/index-7-YBurka.js +0 -716
- package/dist/assets/index-CFBUnxSZ.css +0 -32
- package/node-server/dist/llm/index.d.ts +0 -5
- package/node-server/dist/llm/index.js +0 -21
- package/node-server/dist/llm/llm-service.d.ts +0 -77
- package/node-server/dist/llm/llm-service.js +0 -454
- package/node-server/dist/llm/types.d.ts +0 -40
- package/node-server/dist/llm/types.js +0 -15
- package/node-server/dist/routes/llm.d.ts +0 -8
- 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
|
+
}
|