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,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;
|
|
Binary file
|
|
@@ -13,8 +13,9 @@ interface TerminalSession {
|
|
|
13
13
|
created: number;
|
|
14
14
|
lastActivity: number;
|
|
15
15
|
outputBuffer: string;
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
*
|
|
91
|
+
* Subscribe to a terminal's output. Returns an unsubscribe function
|
|
92
|
+
* (no-op if the terminal is gone).
|
|
84
93
|
*/
|
|
85
|
-
|
|
94
|
+
addDataListener(id: string, callback: (data: string) => void): () => void;
|
|
86
95
|
/**
|
|
87
|
-
*
|
|
96
|
+
* Subscribe to a terminal's exit. Returns an unsubscribe function.
|
|
88
97
|
*/
|
|
89
|
-
|
|
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
|
|
86
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
*
|
|
299
|
+
* Subscribe to a terminal's output. Returns an unsubscribe function
|
|
300
|
+
* (no-op if the terminal is gone).
|
|
220
301
|
*/
|
|
221
|
-
|
|
302
|
+
addDataListener(id, callback) {
|
|
222
303
|
const session = this.sessions.get(id);
|
|
223
|
-
if (session)
|
|
224
|
-
|
|
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
|
-
*
|
|
310
|
+
* Subscribe to a terminal's exit. Returns an unsubscribe function.
|
|
229
311
|
*/
|
|
230
|
-
|
|
312
|
+
addExitListener(id, callback) {
|
|
231
313
|
const session = this.sessions.get(id);
|
|
232
|
-
if (session)
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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.
|
|
242
|
-
|
|
243
|
-
|
|
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
|
});
|
package/node-server/package.json
CHANGED