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.
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,57 @@
1
+ /**
2
+ * Terminal Bindings
3
+ *
4
+ * A notebook has exactly one binding per plane (shell / agent) that decides
5
+ * which named pty its panel attaches to — attach is always deterministic and
6
+ * duplicates are impossible by construction (get-or-create on the name).
7
+ *
8
+ * Scopes:
9
+ * server → the per-server shared terminal (well-known name `srv-main`)
10
+ * project → shared by all notebooks in the same directory (`proj-<hash>-<dir>`)
11
+ * notebook → private to the notebook (`nb-<hash>-<plane>-<slug>` — the exact
12
+ * scheme the panel has always used, so pre-binding ptys with live
13
+ * jobs are grandfathered in, not orphaned)
14
+ * named → a user-chosen name (normalized like every pty name)
15
+ *
16
+ * Persisted as JSON at ~/.nebula/terminal-bindings.json so a notebook's
17
+ * binding follows it across browsers (same pattern as the agent registry).
18
+ */
19
+ export type TerminalPlane = 'shell' | 'agent';
20
+ export type TerminalBindingScope = 'server' | 'project' | 'notebook' | 'named';
21
+ export interface TerminalBinding {
22
+ filePath: string;
23
+ plane: TerminalPlane;
24
+ scope: TerminalBindingScope;
25
+ /** Resolved pty name the panel attaches to (get-or-create). Null for
26
+ * agent/project bindings — the client derives it from the chosen workdir. */
27
+ name: string | null;
28
+ /** The raw user-supplied name for scope 'named'. */
29
+ customName?: string;
30
+ updatedAt: number;
31
+ }
32
+ /** Well-known name of the per-server shared shell. */
33
+ export declare const SHARED_SHELL_NAME = "srv-main";
34
+ /** Well-known name of the per-server shared AGENT terminal. */
35
+ export declare const SHARED_AGENT_NAME = "agent-srv";
36
+ /**
37
+ * Resolve a (plane, scope) to the pty name it binds to. Pure — no I/O.
38
+ *
39
+ * Agent-plane specifics: names double as agent-record keys. `project` returns
40
+ * null — the workdir is a client-side choice (workdir chip), so the client
41
+ * derives the legacy `agent-<hash>-<slug>` name from it (which is also why
42
+ * every pre-binding record keeps working). Named agents get an `agent-`
43
+ * prefix so they can never collide with a shell terminal of the same name.
44
+ */
45
+ export declare function resolveBindingName(plane: TerminalPlane, scope: TerminalBindingScope, filePath: string, customName?: string): string | null;
46
+ export declare class TerminalBindingStore {
47
+ private file;
48
+ private bindings;
49
+ constructor(file?: string);
50
+ private key;
51
+ private load;
52
+ private persist;
53
+ get(filePath: string, plane: TerminalPlane): TerminalBinding | null;
54
+ set(filePath: string, plane: TerminalPlane, scope: TerminalBindingScope, customName?: string): TerminalBinding;
55
+ delete(filePath: string, plane: TerminalPlane): void;
56
+ }
57
+ export declare const terminalBindings: TerminalBindingStore;
@@ -13,8 +13,9 @@ interface TerminalSession {
13
13
  created: number;
14
14
  lastActivity: number;
15
15
  outputBuffer: string;
16
- onData: ((data: string) => void) | null;
17
- onExit: ((code: number) => void) | null;
16
+ activeModes: Set<number>;
17
+ dataListeners: Set<(data: string) => void>;
18
+ exitListeners: Set<(code: number) => void>;
18
19
  }
19
20
  export declare class PtyManager {
20
21
  private sessions;
@@ -48,6 +49,13 @@ export declare class PtyManager {
48
49
  * Get a terminal session by ID
49
50
  */
50
51
  get(id: string): TerminalSession | undefined;
52
+ /**
53
+ * Whether the pty's shell has any child process — i.e. something (an agent
54
+ * CLI, an ssh hop, a user command) is actually running in it. `false` means
55
+ * a bare idle shell with certainty; `null` means unknown (no such pty, or
56
+ * pgrep unavailable) and callers must not conclude anything from it.
57
+ */
58
+ hasLiveChild(id: string): Promise<boolean | null>;
51
59
  /**
52
60
  * Get terminal info by ID
53
61
  */
@@ -80,17 +88,26 @@ export declare class PtyManager {
80
88
  */
81
89
  kill(id: string): boolean;
82
90
  /**
83
- * Set data listener for a terminal (for WebSocket connection)
91
+ * Subscribe to a terminal's output. Returns an unsubscribe function
92
+ * (no-op if the terminal is gone).
84
93
  */
85
- setOnData(id: string, callback: ((data: string) => void) | null): void;
94
+ addDataListener(id: string, callback: (data: string) => void): () => void;
86
95
  /**
87
- * Set exit listener for a terminal
96
+ * Subscribe to a terminal's exit. Returns an unsubscribe function.
88
97
  */
89
- setOnExit(id: string, callback: ((code: number) => void) | null): void;
98
+ addExitListener(id: string, callback: (code: number) => void): () => void;
90
99
  /**
91
100
  * Get buffered output for reconnection
92
101
  */
93
102
  getOutputBuffer(id: string): string;
103
+ /**
104
+ * Sequences that reassert the sticky input modes (see TRACKED_INPUT_MODES) to
105
+ * the authoritative state observed on the full pty stream. Appended after the
106
+ * replay buffer so a reconnect never leaves mouse/focus reporting dangling on
107
+ * at the shell: the 100 KB buffer trim can drop or split the app's own
108
+ * mode-reset, and this puts it back deterministically.
109
+ */
110
+ getModeReset(id: string): string;
94
111
  /**
95
112
  * Kill all terminal sessions (for cleanup)
96
113
  */
@@ -38,9 +38,23 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.ptyManager = exports.PtyManager = void 0;
40
40
  const path = __importStar(require("path"));
41
+ const fs_1 = require("fs");
42
+ const child_process_1 = require("child_process");
41
43
  const pty = __importStar(require("@homebridge/node-pty-prebuilt-multiarch"));
42
44
  const uuid_1 = require("uuid");
43
45
  const types_1 = require("./types");
46
+ // Sticky DEC private modes that turn mouse moves / focus changes into garbage
47
+ // *input* when left enabled at a shell prompt (the classic "reconnected terminal
48
+ // spews <b;x;yM sequences"). Tracked from the raw pty stream so a reconnect can
49
+ // restore the AUTHORITATIVE state even when the enabling/disabling sequence has
50
+ // scrolled out of — or been split by — the 100 KB buffer trim. 1000 click /
51
+ // 1002 button-drag / 1003 any-motion mouse · 1004 focus events · 1006 SGR
52
+ // encoding. (Bracketed paste 2004 is intentionally excluded — the shell prompt
53
+ // toggles it itself, so forcing it would fight the shell.)
54
+ const TRACKED_INPUT_MODES = [1000, 1002, 1003, 1004, 1006];
55
+ // DECSET (…h) / DECRST (…l); params may be ;-separated, e.g. \x1b[?1000;1006h
56
+ // eslint-disable-next-line no-control-regex
57
+ const DEC_PRIVATE_MODE_RE = /\x1b\[\?([0-9;]+)([hl])/g;
44
58
  class PtyManager {
45
59
  sessions = new Map();
46
60
  /**
@@ -82,8 +96,15 @@ class PtyManager {
82
96
  // client the MCP uses (packages/mcp) — agents in the built-in terminal
83
97
  // can drive notebooks without any MCP registration.
84
98
  const nebulaCliBinDir = path.resolve(__dirname, '..', '..', '..', 'packages', 'mcp', 'bin');
85
- // Spawn PTY process
86
- const ptyProcess = pty.spawn(shell, [], {
99
+ // Spawn as a LOGIN shell (-l): Nebula terminals must see the same
100
+ // environment as the user's ssh sessions. Non-login shells skip
101
+ // .zprofile/.bash_profile, so env set there (agent auth tokens like
102
+ // CLAUDE_CODE_OAUTH_TOKEN, module inits, PATH) silently vanished from
103
+ // server-side agent launches — Claude then fell back to its stored OAuth
104
+ // session and demanded /login whenever that had expired. Windows shells
105
+ // don't take -l; posix only.
106
+ const shellArgs = process.platform === 'win32' ? [] : ['-l'];
107
+ const ptyProcess = pty.spawn(shell, shellArgs, {
87
108
  name: 'xterm-256color',
88
109
  cols,
89
110
  rows,
@@ -106,27 +127,50 @@ class PtyManager {
106
127
  created: Date.now(),
107
128
  lastActivity: Date.now(),
108
129
  outputBuffer: '',
109
- onData: null,
110
- onExit: null,
130
+ activeModes: new Set(),
131
+ dataListeners: new Set(),
132
+ exitListeners: new Set(),
111
133
  };
112
134
  // Handle PTY output
113
135
  ptyProcess.onData((data) => {
114
136
  session.lastActivity = Date.now();
115
137
  // Append to output buffer for reconnection
116
138
  session.outputBuffer += data;
139
+ // Track sticky input-mode transitions from the RAW stream (before the
140
+ // trim below can drop or split them) so replay can reassert the true state.
141
+ DEC_PRIVATE_MODE_RE.lastIndex = 0;
142
+ let modeMatch;
143
+ while ((modeMatch = DEC_PRIVATE_MODE_RE.exec(data)) !== null) {
144
+ const enable = modeMatch[2] === 'h';
145
+ for (const p of modeMatch[1].split(';')) {
146
+ const n = Number(p);
147
+ if (TRACKED_INPUT_MODES.includes(n)) {
148
+ if (enable)
149
+ session.activeModes.add(n);
150
+ else
151
+ session.activeModes.delete(n);
152
+ }
153
+ }
154
+ }
117
155
  // Trim buffer if too large
118
156
  if (session.outputBuffer.length > types_1.OUTPUT_BUFFER_MAX_SIZE) {
119
157
  session.outputBuffer = session.outputBuffer.slice(-types_1.OUTPUT_BUFFER_TRIM_SIZE);
120
158
  }
121
- // Forward to listener if connected
122
- if (session.onData) {
123
- session.onData(data);
159
+ // Forward to all listeners (websocket clients, watchers)
160
+ for (const cb of session.dataListeners) {
161
+ try {
162
+ cb(data);
163
+ }
164
+ catch { /* one bad listener must not break the rest */ }
124
165
  }
125
166
  });
126
167
  // Handle PTY exit
127
168
  ptyProcess.onExit(({ exitCode }) => {
128
- if (session.onExit) {
129
- session.onExit(exitCode);
169
+ for (const cb of session.exitListeners) {
170
+ try {
171
+ cb(exitCode);
172
+ }
173
+ catch { /* see above */ }
130
174
  }
131
175
  this.sessions.delete(id);
132
176
  });
@@ -154,6 +198,36 @@ class PtyManager {
154
198
  get(id) {
155
199
  return this.sessions.get(id);
156
200
  }
201
+ /**
202
+ * Whether the pty's shell has any child process — i.e. something (an agent
203
+ * CLI, an ssh hop, a user command) is actually running in it. `false` means
204
+ * a bare idle shell with certainty; `null` means unknown (no such pty, or
205
+ * pgrep unavailable) and callers must not conclude anything from it.
206
+ */
207
+ async hasLiveChild(id) {
208
+ const session = this.sessions.get(id);
209
+ if (!session)
210
+ return null;
211
+ const pid = session.pty.pid;
212
+ // Fast path (Linux): the kernel lists a process's children directly —
213
+ // an O(1) ~3ms read, vs pgrep's O(all processes) table scan (~50ms on a
214
+ // busy shared login node with 1300+ processes). Absent on macOS and on
215
+ // kernels without CONFIG_PROC_CHILDREN — fall back to pgrep there.
216
+ try {
217
+ const children = await fs_1.promises.readFile(`/proc/${pid}/task/${pid}/children`, 'utf-8');
218
+ return children.trim().length > 0;
219
+ }
220
+ catch { /* not Linux (or file gone with the process) — pgrep decides */ }
221
+ return new Promise((resolve) => {
222
+ (0, child_process_1.execFile)('pgrep', ['-P', String(pid)], (error) => {
223
+ if (!error)
224
+ return resolve(true); // exit 0: at least one child
225
+ // pgrep exits 1 for "no processes matched" — that's a definitive no.
226
+ const code = error.code;
227
+ resolve(code === 1 ? false : null);
228
+ });
229
+ });
230
+ }
157
231
  /**
158
232
  * Get terminal info by ID
159
233
  */
@@ -185,6 +259,12 @@ class PtyManager {
185
259
  const session = this.sessions.get(id);
186
260
  if (!session)
187
261
  return false;
262
+ // Skip no-op resizes: TIOCSWINSZ raises SIGWINCH even when the size is
263
+ // unchanged, forcing a full-screen repaint from any running TUI. On a
264
+ // reconnect several resize messages arrive with the SAME dimensions, and
265
+ // that repaint burst is what stalls input after a refresh.
266
+ if (session.cols === cols && session.rows === rows)
267
+ return true;
188
268
  session.pty.resize(cols, rows);
189
269
  session.cols = cols;
190
270
  session.rows = rows;
@@ -216,22 +296,25 @@ class PtyManager {
216
296
  return true;
217
297
  }
218
298
  /**
219
- * Set data listener for a terminal (for WebSocket connection)
299
+ * Subscribe to a terminal's output. Returns an unsubscribe function
300
+ * (no-op if the terminal is gone).
220
301
  */
221
- setOnData(id, callback) {
302
+ addDataListener(id, callback) {
222
303
  const session = this.sessions.get(id);
223
- if (session) {
224
- session.onData = callback;
225
- }
304
+ if (!session)
305
+ return () => { };
306
+ session.dataListeners.add(callback);
307
+ return () => { this.sessions.get(id)?.dataListeners.delete(callback); };
226
308
  }
227
309
  /**
228
- * Set exit listener for a terminal
310
+ * Subscribe to a terminal's exit. Returns an unsubscribe function.
229
311
  */
230
- setOnExit(id, callback) {
312
+ addExitListener(id, callback) {
231
313
  const session = this.sessions.get(id);
232
- if (session) {
233
- session.onExit = callback;
234
- }
314
+ if (!session)
315
+ return () => { };
316
+ session.exitListeners.add(callback);
317
+ return () => { this.sessions.get(id)?.exitListeners.delete(callback); };
235
318
  }
236
319
  /**
237
320
  * Get buffered output for reconnection
@@ -240,6 +323,34 @@ class PtyManager {
240
323
  const session = this.sessions.get(id);
241
324
  return session?.outputBuffer || '';
242
325
  }
326
+ /**
327
+ * Sequences that reassert the sticky input modes (see TRACKED_INPUT_MODES) to
328
+ * the authoritative state observed on the full pty stream. Appended after the
329
+ * replay buffer so a reconnect never leaves mouse/focus reporting dangling on
330
+ * at the shell: the 100 KB buffer trim can drop or split the app's own
331
+ * mode-reset, and this puts it back deterministically.
332
+ */
333
+ getModeReset(id) {
334
+ const session = this.sessions.get(id);
335
+ if (!session)
336
+ return '';
337
+ // If the pty's FOREGROUND process is a plain shell, no TUI can be holding
338
+ // the sticky input modes — any tracked "on" state is stale (a TUI died
339
+ // without its teardown bytes reaching the pty, e.g. an agent riding a
340
+ // collapsed reverse tunnel, or a SIGKILL). Reasserting it would turn the
341
+ // user's mouse movements into SGR garbage typed at the prompt, so reset
342
+ // everything OFF instead.
343
+ try {
344
+ const fg = (session.pty.process || '').split('/').pop() || '';
345
+ if (/^-?(bash|zsh|sh|dash|fish|tcsh|ksh)$/.test(fg)) {
346
+ session.activeModes.clear();
347
+ }
348
+ }
349
+ catch { /* can't inspect foreground — keep tracked state */ }
350
+ return TRACKED_INPUT_MODES
351
+ .map((n) => `\x1b[?${n}${session.activeModes.has(n) ? 'h' : 'l'}`)
352
+ .join('');
353
+ }
243
354
  /**
244
355
  * Kill all terminal sessions (for cleanup)
245
356
  */
@@ -44,6 +44,8 @@ const net = __importStar(require("net"));
44
44
  const os = __importStar(require("os"));
45
45
  const path = __importStar(require("path"));
46
46
  const pty_manager_1 = require("./pty-manager");
47
+ const agent_registry_1 = require("./agent-registry");
48
+ const binding_store_1 = require("./binding-store");
47
49
  const fs_service_1 = require("../fs/fs-service");
48
50
  // Get configured root directory for terminals
49
51
  function getDefaultCwd() {
@@ -105,6 +107,38 @@ async function setupTerminalRoutes(fastify) {
105
107
  return reply.send({ up: result.up, ssh: result.ssh });
106
108
  });
107
109
  // List all terminals
110
+ // ---- Agent registry: project-scoped agent sessions (see agent-registry.ts) ----
111
+ fastify.get('/api/agents', async (_request, reply) => {
112
+ return reply.send({ agents: await agent_registry_1.agentRegistry.listEnriched() });
113
+ });
114
+ fastify.post('/api/agents/register', async (request, reply) => {
115
+ const b = (request.body ?? {});
116
+ if (!b.terminalId || !b.kind || !b.workdir) {
117
+ return reply.code(400).send({ error: 'terminalId, kind, workdir required' });
118
+ }
119
+ const record = agent_registry_1.agentRegistry.register({
120
+ terminalId: b.terminalId,
121
+ kind: b.kind === 'codex' ? 'codex' : 'claude',
122
+ workdir: b.workdir,
123
+ location: b.location === 'remote' ? 'remote' : 'server',
124
+ sessionId: b.sessionId,
125
+ launchedFrom: b.launchedFrom,
126
+ mirrorSlug: b.mirrorSlug,
127
+ });
128
+ return reply.send(record);
129
+ });
130
+ fastify.post('/api/agents/:id/hibernate', async (request, reply) => {
131
+ const { id } = request.params;
132
+ if (!agent_registry_1.agentRegistry.hibernate(id))
133
+ return reply.code(404).send({ error: 'unknown agent' });
134
+ return reply.send({ ok: true });
135
+ });
136
+ fastify.delete('/api/agents/:id', async (request, reply) => {
137
+ const { id } = request.params;
138
+ if (!agent_registry_1.agentRegistry.remove(id))
139
+ return reply.code(404).send({ error: 'unknown agent' });
140
+ return reply.send({ ok: true });
141
+ });
108
142
  fastify.get('/api/terminals', async (_request, reply) => {
109
143
  return reply.send(pty_manager_1.ptyManager.list());
110
144
  });
@@ -145,6 +179,63 @@ async function setupTerminalRoutes(fastify) {
145
179
  });
146
180
  }
147
181
  });
182
+ // --- Terminal bindings: which named pty a notebook's panel attaches to ---
183
+ // GET returns the stored binding, or the plane default when none is stored
184
+ // (shell → server-shared srv-main; agent → project scope, name resolved
185
+ // client-side from the chosen workdir).
186
+ fastify.get('/api/terminals/binding', async (request, reply) => {
187
+ const { file_path, plane } = request.query;
188
+ if (!file_path || (plane !== 'shell' && plane !== 'agent')) {
189
+ return reply.code(400).send({ detail: 'file_path and plane (shell|agent) are required' });
190
+ }
191
+ const stored = binding_store_1.terminalBindings.get(file_path, plane);
192
+ if (stored) {
193
+ return reply.send({
194
+ plane, scope: stored.scope, name: stored.name,
195
+ custom_name: stored.customName ?? null, stored: true,
196
+ });
197
+ }
198
+ if (plane === 'shell') {
199
+ return reply.send({ plane, scope: 'server', name: binding_store_1.SHARED_SHELL_NAME, custom_name: null, stored: false });
200
+ }
201
+ return reply.send({ plane, scope: 'project', name: null, custom_name: null, stored: false });
202
+ });
203
+ fastify.put('/api/terminals/binding', async (request, reply) => {
204
+ const { file_path, plane, scope, name } = request.body || {};
205
+ if (!file_path || (plane !== 'shell' && plane !== 'agent')) {
206
+ return reply.code(400).send({ detail: 'file_path and plane (shell|agent) are required' });
207
+ }
208
+ if (!['server', 'project', 'notebook', 'named'].includes(scope || '')) {
209
+ return reply.code(400).send({ detail: 'scope must be server|project|notebook|named' });
210
+ }
211
+ try {
212
+ const binding = binding_store_1.terminalBindings.set(file_path, plane, scope, name);
213
+ return reply.send({
214
+ plane, scope: binding.scope, name: binding.name,
215
+ custom_name: binding.customName ?? null, stored: true,
216
+ });
217
+ }
218
+ catch (e) {
219
+ return reply.code(400).send({ detail: e instanceof Error ? e.message : 'Invalid binding' });
220
+ }
221
+ });
222
+ fastify.delete('/api/terminals/binding', async (request, reply) => {
223
+ const { file_path, plane } = request.query;
224
+ if (!file_path || (plane !== 'shell' && plane !== 'agent')) {
225
+ return reply.code(400).send({ detail: 'file_path and plane (shell|agent) are required' });
226
+ }
227
+ binding_store_1.terminalBindings.delete(file_path, plane);
228
+ return reply.send({ ok: true });
229
+ });
230
+ // Process-table probe: does this pty's shell have a live child? Clients MUST
231
+ // check this before typing a launch command into an existing pty — records
232
+ // can mis-score liveness (a hand-resumed agent never re-registers), and a
233
+ // launch line typed into a live TUI becomes a chat prompt (lab report).
234
+ // busy:null = unknown (pty gone / pgrep unavailable) — claim nothing.
235
+ fastify.get('/api/terminals/:id/busy', async (request, reply) => {
236
+ const busy = await pty_manager_1.ptyManager.hasLiveChild(request.params.id);
237
+ return reply.send({ busy });
238
+ });
148
239
  // Get terminal info
149
240
  fastify.get('/api/terminals/:id', async (request, reply) => {
150
241
  const terminal = pty_manager_1.ptyManager.getTerminalInfo(request.params.id);
@@ -225,28 +316,28 @@ function setupTerminalWebSocket(server) {
225
316
  // Send buffered output for reconnection
226
317
  const buffer = pty_manager_1.ptyManager.getOutputBuffer(terminalId);
227
318
  if (buffer) {
228
- const replayMsg = { type: 'replay', data: buffer };
319
+ // Append an authoritative reassert of the sticky input modes so a
320
+ // reconnect never leaves mouse/focus reporting dangling on at the shell
321
+ // (the buffer trim can drop or split the app's own mode-reset).
322
+ const info = pty_manager_1.ptyManager.getTerminalInfo(terminalId);
323
+ const replayMsg = {
324
+ type: 'replay',
325
+ data: buffer + pty_manager_1.ptyManager.getModeReset(terminalId),
326
+ cols: info?.cols,
327
+ rows: info?.rows,
328
+ };
229
329
  ws.send(JSON.stringify(replayMsg));
230
330
  }
231
- // Collaborative mode: broadcast output to ALL connected clients
232
- const broadcastOutput = (data) => {
233
- const msg = { type: 'output', data };
234
- const msgStr = JSON.stringify(msg);
235
- for (const conn of connections) {
236
- if (conn.readyState === ws_1.WebSocket.OPEN) {
237
- conn.send(msgStr);
238
- }
331
+ // One subscription per websocket, sending only to ITS socket — the pty
332
+ // layer now multicasts, so no shared-closure broadcast trickery needed.
333
+ const unsubData = pty_manager_1.ptyManager.addDataListener(terminalId, (data) => {
334
+ if (ws.readyState === ws_1.WebSocket.OPEN) {
335
+ ws.send(JSON.stringify({ type: 'output', data }));
239
336
  }
240
- };
241
- pty_manager_1.ptyManager.setOnData(terminalId, broadcastOutput);
242
- // Set up exit listener - broadcast to all connections
243
- pty_manager_1.ptyManager.setOnExit(terminalId, (code) => {
244
- const msg = { type: 'exit', code };
245
- const msgStr = JSON.stringify(msg);
246
- for (const conn of connections) {
247
- if (conn.readyState === ws_1.WebSocket.OPEN) {
248
- conn.send(msgStr);
249
- }
337
+ });
338
+ const unsubExit = pty_manager_1.ptyManager.addExitListener(terminalId, (code) => {
339
+ if (ws.readyState === ws_1.WebSocket.OPEN) {
340
+ ws.send(JSON.stringify({ type: 'exit', code }));
250
341
  }
251
342
  });
252
343
  // Handle incoming messages
@@ -271,13 +362,13 @@ function setupTerminalWebSocket(server) {
271
362
  // Handle disconnection
272
363
  ws.on('close', () => {
273
364
  console.log(`[Terminal] WebSocket disconnected from terminal ${terminalId}`);
365
+ unsubData();
366
+ unsubExit();
274
367
  const connections = wsConnections.get(terminalId);
275
368
  if (connections) {
276
369
  connections.delete(ws);
277
370
  if (connections.size === 0) {
278
371
  wsConnections.delete(terminalId);
279
- // Clear the data listener when no connections
280
- pty_manager_1.ptyManager.setOnData(terminalId, null);
281
372
  }
282
373
  }
283
374
  });
@@ -35,6 +35,8 @@ export type ServerMessage = {
35
35
  } | {
36
36
  type: 'replay';
37
37
  data: string;
38
+ cols?: number;
39
+ rows?: number;
38
40
  } | {
39
41
  type: 'exit';
40
42
  code: number;
@@ -20,6 +20,7 @@
20
20
  "better-sqlite3": "^11.0.0",
21
21
  "fastify": "^5.8.2",
22
22
  "jsonwebtoken": "^9.0.2",
23
+ "nebula-autocomplete": "file:../packages/autocomplete",
23
24
  "otplib": "^12.0.1",
24
25
  "qrcode-terminal": "^0.12.0",
25
26
  "uuid": "^9.0.1",