@monotykamary/localterm-server 2.66.8 → 2.66.9
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/dist/agent-git-status.d.ts +3 -0
- package/dist/agent-git-status.d.ts.map +1 -0
- package/dist/agent-git-status.js +46 -0
- package/dist/agent-git-status.js.map +1 -0
- package/dist/agent-log-utils.d.ts +18 -0
- package/dist/agent-log-utils.d.ts.map +1 -0
- package/dist/agent-log-utils.js +123 -0
- package/dist/agent-log-utils.js.map +1 -0
- package/dist/agent-models.d.ts +4 -0
- package/dist/agent-models.d.ts.map +1 -0
- package/dist/agent-models.js +100 -0
- package/dist/agent-models.js.map +1 -0
- package/dist/agent-runner.d.ts +3 -5
- package/dist/agent-runner.d.ts.map +1 -1
- package/dist/agent-runner.js +8 -522
- package/dist/agent-runner.js.map +1 -1
- package/dist/agent-session-reader.d.ts +3 -0
- package/dist/agent-session-reader.d.ts.map +1 -0
- package/dist/agent-session-reader.js +113 -0
- package/dist/agent-session-reader.js.map +1 -0
- package/dist/build-pty-environment.d.ts +8 -0
- package/dist/build-pty-environment.d.ts.map +1 -0
- package/dist/build-pty-environment.js +44 -0
- package/dist/build-pty-environment.js.map +1 -0
- package/dist/caffeinate-automatic-detector.d.ts +57 -0
- package/dist/caffeinate-automatic-detector.d.ts.map +1 -0
- package/dist/caffeinate-automatic-detector.js +275 -0
- package/dist/caffeinate-automatic-detector.js.map +1 -0
- package/dist/caffeinate-battery-guard.d.ts +35 -0
- package/dist/caffeinate-battery-guard.d.ts.map +1 -0
- package/dist/caffeinate-battery-guard.js +190 -0
- package/dist/caffeinate-battery-guard.js.map +1 -0
- package/dist/caffeinate-manager.d.ts +5 -31
- package/dist/caffeinate-manager.d.ts.map +1 -1
- package/dist/caffeinate-manager.js +55 -400
- package/dist/caffeinate-manager.js.map +1 -1
- package/dist/cdp/cdp-client.d.ts +4 -70
- package/dist/cdp/cdp-client.d.ts.map +1 -1
- package/dist/cdp/cdp-client.js +58 -418
- package/dist/cdp/cdp-client.js.map +1 -1
- package/dist/cdp/cdp-connection.d.ts +72 -0
- package/dist/cdp/cdp-connection.d.ts.map +1 -0
- package/dist/cdp/cdp-connection.js +272 -0
- package/dist/cdp/cdp-connection.js.map +1 -0
- package/dist/cdp/constants.d.ts +6 -0
- package/dist/cdp/constants.d.ts.map +1 -0
- package/dist/cdp/constants.js +9 -0
- package/dist/cdp/constants.js.map +1 -0
- package/dist/cdp/target-registry.d.ts +41 -0
- package/dist/cdp/target-registry.d.ts.map +1 -0
- package/dist/cdp/target-registry.js +118 -0
- package/dist/cdp/target-registry.js.map +1 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +2 -0
- package/dist/constants.js.map +1 -1
- package/dist/git-branch-metadata.d.ts +11 -0
- package/dist/git-branch-metadata.d.ts.map +1 -0
- package/dist/git-branch-metadata.js +85 -0
- package/dist/git-branch-metadata.js.map +1 -0
- package/dist/git-diff-cache.d.ts +13 -0
- package/dist/git-diff-cache.d.ts.map +1 -0
- package/dist/git-diff-cache.js +32 -0
- package/dist/git-diff-cache.js.map +1 -0
- package/dist/git-diff-parser.d.ts +15 -0
- package/dist/git-diff-parser.d.ts.map +1 -0
- package/dist/git-diff-parser.js +196 -0
- package/dist/git-diff-parser.js.map +1 -0
- package/dist/git-diff-service.d.ts +10 -0
- package/dist/git-diff-service.d.ts.map +1 -0
- package/dist/git-diff-service.js +395 -0
- package/dist/git-diff-service.js.map +1 -0
- package/dist/git-diff.d.ts +8 -41
- package/dist/git-diff.d.ts.map +1 -1
- package/dist/git-diff.js +5 -944
- package/dist/git-diff.js.map +1 -1
- package/dist/github-pr.d.ts +32 -0
- package/dist/github-pr.d.ts.map +1 -0
- package/dist/github-pr.js +266 -0
- package/dist/github-pr.js.map +1 -0
- package/dist/pi-binary-resolver.d.ts +6 -0
- package/dist/pi-binary-resolver.d.ts.map +1 -0
- package/dist/pi-binary-resolver.js +70 -0
- package/dist/pi-binary-resolver.js.map +1 -0
- package/dist/pi-rpc-client.d.ts +14 -0
- package/dist/pi-rpc-client.d.ts.map +1 -0
- package/dist/pi-rpc-client.js +87 -0
- package/dist/pi-rpc-client.js.map +1 -0
- package/dist/session-client-hub.d.ts +57 -0
- package/dist/session-client-hub.d.ts.map +1 -0
- package/dist/session-client-hub.js +435 -0
- package/dist/session-client-hub.js.map +1 -0
- package/dist/session-command-executor.d.ts +14 -0
- package/dist/session-command-executor.d.ts.map +1 -0
- package/dist/session-command-executor.js +207 -0
- package/dist/session-command-executor.js.map +1 -0
- package/dist/session-git-event-bridge.d.ts +16 -0
- package/dist/session-git-event-bridge.d.ts.map +1 -0
- package/dist/session-git-event-bridge.js +56 -0
- package/dist/session-git-event-bridge.js.map +1 -0
- package/dist/session-lifecycle-policy.d.ts +15 -0
- package/dist/session-lifecycle-policy.d.ts.map +1 -0
- package/dist/session-lifecycle-policy.js +105 -0
- package/dist/session-lifecycle-policy.js.map +1 -0
- package/dist/session-manager.d.ts +11 -42
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +72 -987
- package/dist/session-manager.js.map +1 -1
- package/dist/session-output-coordinator.d.ts +21 -0
- package/dist/session-output-coordinator.d.ts.map +1 -0
- package/dist/session-output-coordinator.js +134 -0
- package/dist/session-output-coordinator.js.map +1 -0
- package/dist/session-output-transport.d.ts +22 -0
- package/dist/session-output-transport.d.ts.map +1 -0
- package/dist/session-output-transport.js +160 -0
- package/dist/session-output-transport.js.map +1 -0
- package/dist/session.d.ts +0 -4
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +11 -258
- package/dist/session.js.map +1 -1
- package/dist/shell-hook-builder.d.ts +16 -0
- package/dist/shell-hook-builder.d.ts.map +1 -0
- package/dist/shell-hook-builder.js +230 -0
- package/dist/shell-hook-builder.js.map +1 -0
- package/package.json +1 -1
package/dist/session-manager.js
CHANGED
|
@@ -1,49 +1,16 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
1
|
import path from "node:path";
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import { CAPTURE_PANE_MAX_LINES, EXEC_DEFAULT_OUTPUT_LIMIT_BYTES, EXEC_DEFAULT_TIMEOUT_MS, EXEC_EPHEMERAL_SCROLLBACK, EXEC_MAX_OUTPUT_LIMIT_BYTES, EXEC_MAX_TIMEOUT_MS, EXEC_RAW_ACCUMULATE_CAP_BYTES, EXEC_TIMEOUT_INTERRUPT_GRACE_MS, MAX_AUTOMATION_LOG_LENGTH, MAX_CONCURRENT_SESSIONS, MAX_OUTPUT_BYTES, OUTPUT_BATCH_FLUSH_BYTES, OUTPUT_BATCH_WINDOW_MS, SESSION_ACTIVITY_WINDOW_MS, SESSION_GRACE_MS, SESSION_PENDING_PROMOTE_TIMEOUT_MS, WAIT_IDLE_POLL_INTERVAL_MS, WS_BACKPRESSURE_THRESHOLD_BYTES, WS_CLOSE_BACKPRESSURE, WS_OUTBOUND_DRAIN_POLL_MS, WS_OUTBOUND_PAUSE_HIGH_WATER_BYTES, WS_OUTBOUND_RESUME_LOW_WATER_BYTES, WS_READY_STATE_OPEN, WS_OUTPUT_BROTLI, WS_OUTPUT_BROTLI_CTX, WS_OUTPUT_BROTLI_QUALITY, WS_OUTPUT_COMPRESS_THRESHOLD_BYTES, WS_OUTPUT_CTX_HEADER_BYTES, WS_OUTPUT_GZIP, WS_OUTPUT_GZIP_LEVEL, WS_OUTPUT_RAW, } from "./constants.js";
|
|
6
|
-
import { GitDiffWatcher, GIT_DIFF_WATCHER_EVENT_NAMES, } from "./git-diff-watcher.js";
|
|
7
|
-
import { GitMetadataCoordinator } from "./git-metadata-coordinator.js";
|
|
2
|
+
import { SESSION_GRACE_MS, SESSION_PENDING_PROMOTE_TIMEOUT_MS } from "./constants.js";
|
|
3
|
+
import { GitDiffWatcher } from "./git-diff-watcher.js";
|
|
8
4
|
import { Session } from "./session.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
buf = Buffer.concat([buf, d]);
|
|
16
|
-
});
|
|
17
|
-
let chain = Promise.resolve(Buffer.alloc(0));
|
|
18
|
-
const flush = (bytes) => {
|
|
19
|
-
chain = chain.then(() => new Promise((resolve) => {
|
|
20
|
-
const before = buf.length;
|
|
21
|
-
enc.write(bytes);
|
|
22
|
-
enc.flush(zlib.constants.BROTLI_OPERATION_FLUSH, () => {
|
|
23
|
-
setImmediate(() => {
|
|
24
|
-
const out = buf.subarray(before, buf.length);
|
|
25
|
-
buf = buf.subarray(buf.length);
|
|
26
|
-
resolve(out);
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
}));
|
|
30
|
-
return chain;
|
|
31
|
-
};
|
|
32
|
-
const release = () => {
|
|
33
|
-
try {
|
|
34
|
-
enc.destroy();
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
/* already closed */
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
return { flush, release };
|
|
41
|
-
};
|
|
5
|
+
import { SessionClientHub } from "./session-client-hub.js";
|
|
6
|
+
import { SessionCommandExecutor } from "./session-command-executor.js";
|
|
7
|
+
import { SessionGitEventBridge } from "./session-git-event-bridge.js";
|
|
8
|
+
import { SessionLifecyclePolicy } from "./session-lifecycle-policy.js";
|
|
9
|
+
import { SessionOutputCoordinator } from "./session-output-coordinator.js";
|
|
10
|
+
import { SessionOutputTransport } from "./session-output-transport.js";
|
|
42
11
|
import { createSynchronizedOutputEndDetector, } from "./utils/create-synchronized-output-end-detector.js";
|
|
43
12
|
import { resolveNamedKeys } from "./utils/named-keys.js";
|
|
44
13
|
import { deletePasteImagesForSession } from "./utils/paste-image-store.js";
|
|
45
|
-
import { stripAnsi } from "./utils/strip-ansi.js";
|
|
46
|
-
import { getBufferedAmount } from "./utils/ws-socket.js";
|
|
47
14
|
// Owns every live PTY for the daemon's lifetime. A Session is created on spawn
|
|
48
15
|
// and stays here until its shell exits, it's explicitly killed, or its
|
|
49
16
|
// no-clients grace timer fires — NOT the instant its client disconnects. One
|
|
@@ -59,36 +26,44 @@ import { getBufferedAmount } from "./utils/ws-socket.js";
|
|
|
59
26
|
// backpressure instead of dropping the connection.
|
|
60
27
|
export class SessionManager {
|
|
61
28
|
sessions = new Map();
|
|
62
|
-
wsToClient = new Map();
|
|
63
29
|
lastOutputAtByPid = new Map();
|
|
64
|
-
coordinatorsByCwd = new Map();
|
|
65
30
|
hooks;
|
|
66
31
|
sendControl;
|
|
67
|
-
|
|
68
|
-
|
|
32
|
+
outputTransport;
|
|
33
|
+
clientHub;
|
|
34
|
+
commandExecutor;
|
|
35
|
+
gitEventBridge;
|
|
36
|
+
lifecyclePolicy;
|
|
37
|
+
outputCoordinator;
|
|
69
38
|
shimsDir;
|
|
70
39
|
constructor(options) {
|
|
71
40
|
this.hooks = options.hooks;
|
|
72
41
|
this.sendControl = options.sendControl;
|
|
73
|
-
this.
|
|
74
|
-
this.
|
|
75
|
-
|
|
42
|
+
this.outputTransport = new SessionOutputTransport(options.sendControl);
|
|
43
|
+
this.lifecyclePolicy = new SessionLifecyclePolicy(options.getGraceMs ?? (() => SESSION_GRACE_MS), (managed) => this.tearDown(managed), () => this.hooks.onSessionActivity());
|
|
44
|
+
this.clientHub = new SessionClientHub({
|
|
45
|
+
outputTransport: this.outputTransport,
|
|
46
|
+
sendControl: this.sendControl,
|
|
47
|
+
pendingPromoteTimeoutMs: options.pendingPromoteTimeoutMs ?? SESSION_PENDING_PROMOTE_TIMEOUT_MS,
|
|
48
|
+
sessionFor: (id, owner) => this.sessionFor(id, owner),
|
|
49
|
+
cancelGrace: (managed) => this.lifecyclePolicy.cancelGrace(managed),
|
|
50
|
+
startGrace: (managed) => this.lifecyclePolicy.startGrace(managed),
|
|
51
|
+
onSessionActivity: () => this.hooks.onSessionActivity(),
|
|
52
|
+
});
|
|
53
|
+
this.commandExecutor = new SessionCommandExecutor();
|
|
54
|
+
this.gitEventBridge = new SessionGitEventBridge(this.clientHub, (event, cwd) => this.hooks.onSessionEvent(event, cwd));
|
|
55
|
+
this.outputCoordinator = new SessionOutputCoordinator({
|
|
56
|
+
outputTransport: this.outputTransport,
|
|
57
|
+
noteOutputActivity: (pid) => this.noteOutput(pid),
|
|
58
|
+
onOutputActivity: () => this.hooks.onOutputActivity(),
|
|
59
|
+
});
|
|
76
60
|
this.shimsDir = options.shimsDir;
|
|
77
61
|
}
|
|
78
62
|
size() {
|
|
79
63
|
return this.sessions.size;
|
|
80
64
|
}
|
|
81
65
|
atCapacity() {
|
|
82
|
-
|
|
83
|
-
return false;
|
|
84
|
-
for (const managed of this.sessions.values()) {
|
|
85
|
-
// A dormant, non-pinned session can be evicted to make room. Pinned
|
|
86
|
-
// sessions hold their slots (never silently reaped), so a full cap of
|
|
87
|
-
// pinned sessions surfaces a real capacity error instead of a steal.
|
|
88
|
-
if (managed.clients.size === 0 && !managed.pinned)
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
return true;
|
|
66
|
+
return this.lifecyclePolicy.atCapacity(this.sessions);
|
|
92
67
|
}
|
|
93
68
|
// Shell pids of every live session. The keep-awake manager scopes its `ps`
|
|
94
69
|
// tree walk to these so automatic mode only reacts to programs running inside
|
|
@@ -144,83 +119,19 @@ export class SessionManager {
|
|
|
144
119
|
createdAt: managed.createdAt,
|
|
145
120
|
lastOutputAt: managed.lastOutputAt,
|
|
146
121
|
clients: managed.clients.size,
|
|
147
|
-
clientProfiles: this.clientProfilesFor(managed),
|
|
148
|
-
state: this.computeState(managed),
|
|
122
|
+
clientProfiles: this.clientHub.clientProfilesFor(managed),
|
|
123
|
+
state: this.lifecyclePolicy.computeState(managed),
|
|
149
124
|
pinned: managed.pinned,
|
|
150
125
|
}));
|
|
151
126
|
}
|
|
152
|
-
// The attached clients grouped by their per-browser-profile handle, for the
|
|
153
|
-
// session picker's per-profile peer display. Each entry counts how many of
|
|
154
|
-
// that profile's windows are viewing this PTY; sorted by count desc then id
|
|
155
|
-
// for a stable order (the picker re-ranks its own profile to the front).
|
|
156
|
-
clientProfilesFor(managed) {
|
|
157
|
-
const counts = new Map();
|
|
158
|
-
for (const client of managed.clients) {
|
|
159
|
-
counts.set(client.windowId, (counts.get(client.windowId) ?? 0) + 1);
|
|
160
|
-
}
|
|
161
|
-
return [...counts.entries()]
|
|
162
|
-
.map(([windowId, count]) => ({ windowId, count }))
|
|
163
|
-
.sort((a, b) => b.count - a.count || a.windowId.localeCompare(b.windowId));
|
|
164
|
-
}
|
|
165
|
-
// The persisted workspace manifest: every currently-open tab, grouped by
|
|
166
|
-
// (owner, windowId), recording the live cwd + shell to respawn it in. A
|
|
167
|
-
// session with N attached clients of one windowId contributes N tabs (each
|
|
168
|
-
// viewer is a tab). Automation-run sessions are skipped (their tabs are
|
|
169
|
-
// one-shot), and sessions with no clients (dormant/orphaned) contribute
|
|
170
|
-
// nothing — only tabs that are actively open are restored. Computed from
|
|
171
|
-
// in-memory state, so the daemon snapshots it to disk before a graceful
|
|
172
|
-
// stop (and debounced during life for crash recovery).
|
|
173
127
|
workspaceEntries() {
|
|
174
|
-
|
|
175
|
-
const now = Date.now();
|
|
176
|
-
for (const managed of this.sessions.values()) {
|
|
177
|
-
if (managed.automation)
|
|
178
|
-
continue;
|
|
179
|
-
if (managed.clients.size === 0)
|
|
180
|
-
continue;
|
|
181
|
-
const cwd = managed.session.lastEmittedCwd || managed.session.cwd;
|
|
182
|
-
const shell = managed.session.shell;
|
|
183
|
-
const tab = { cwd, shell };
|
|
184
|
-
for (const client of managed.clients) {
|
|
185
|
-
if (!client.windowId)
|
|
186
|
-
continue;
|
|
187
|
-
const key = `${managed.owner ?? ""}\u0000${client.windowId}`;
|
|
188
|
-
let entry = byKey.get(key);
|
|
189
|
-
if (!entry) {
|
|
190
|
-
entry = { owner: managed.owner, windowId: client.windowId, tabs: [], savedAt: now };
|
|
191
|
-
byKey.set(key, entry);
|
|
192
|
-
}
|
|
193
|
-
entry.tabs.push(tab);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
return [...byKey.values()];
|
|
128
|
+
return this.clientHub.workspaceEntries(this.sessions.values());
|
|
197
129
|
}
|
|
198
|
-
// The (owner, windowId) a socket's tab is viewing, for scoping a CDP
|
|
199
|
-
// workspace restore to the browser profile that just reconnected. Returns
|
|
200
|
-
// null when the socket isn't attached to a session (e.g. a transient
|
|
201
|
-
// mid-detach state).
|
|
202
130
|
clientProfile(ws) {
|
|
203
|
-
|
|
204
|
-
if (!entry)
|
|
205
|
-
return null;
|
|
206
|
-
return { owner: entry.session.owner, windowId: entry.client.windowId };
|
|
131
|
+
return this.clientHub.clientProfile(ws);
|
|
207
132
|
}
|
|
208
|
-
// How many of a browser profile's tabs are currently attached to a (non-
|
|
209
|
-
// automation) session — the "already open" count the restore reconciles
|
|
210
|
-
// the persisted manifest against. Surviving tabs that reattach after a
|
|
211
|
-
// daemon restart are counted here so the daemon only opens the deficit.
|
|
212
133
|
attachedClientCount(owner, windowId) {
|
|
213
|
-
|
|
214
|
-
for (const managed of this.sessions.values()) {
|
|
215
|
-
if (owner !== null && managed.owner !== owner)
|
|
216
|
-
continue;
|
|
217
|
-
if (managed.automation)
|
|
218
|
-
continue;
|
|
219
|
-
for (const client of managed.clients)
|
|
220
|
-
if (client.windowId === windowId)
|
|
221
|
-
count++;
|
|
222
|
-
}
|
|
223
|
-
return count;
|
|
134
|
+
return this.clientHub.attachedClientCount(this.sessions.values(), owner, windowId);
|
|
224
135
|
}
|
|
225
136
|
// Every live PTY whose current cwd is inside `targetPath` (or equals it). A
|
|
226
137
|
// live PTY — attached, dormant (parked in the no-clients grace window), or
|
|
@@ -287,9 +198,7 @@ export class SessionManager {
|
|
|
287
198
|
managed.session.emit("notification", body);
|
|
288
199
|
}
|
|
289
200
|
spawn(input, automation, owner = null) {
|
|
290
|
-
if (this.sessions
|
|
291
|
-
this.evictOldestDormant();
|
|
292
|
-
if (this.sessions.size >= MAX_CONCURRENT_SESSIONS)
|
|
201
|
+
if (!this.lifecyclePolicy.makeRoomForSession(this.sessions))
|
|
293
202
|
return null;
|
|
294
203
|
const session = new Session(this.shimsDir ? { ...input, shimsDir: this.shimsDir } : input);
|
|
295
204
|
const managed = {
|
|
@@ -345,211 +254,23 @@ export class SessionManager {
|
|
|
345
254
|
return null;
|
|
346
255
|
return managed;
|
|
347
256
|
}
|
|
348
|
-
// Attach `ws` to a live PTY by id. Returns the session to reattach to, or
|
|
349
|
-
// null when the id is unknown / already exited — the caller spawns fresh.
|
|
350
257
|
attach(ws, id, owner = null, windowId = "") {
|
|
351
|
-
|
|
352
|
-
if (!managed)
|
|
353
|
-
return null;
|
|
354
|
-
// Re-subscribing cancels the no-clients grace timer (if armed): the shell
|
|
355
|
-
// has a viewer again, so it stays alive.
|
|
356
|
-
this.cancelGrace(managed);
|
|
357
|
-
// Notify existing subscribers a peer joined (a mobile ingested this
|
|
358
|
-
// session's share QR) so a host can react — e.g. the desktop's QR modal
|
|
359
|
-
// auto-closes. Gated on existing clients so a fresh spawn's first attach
|
|
360
|
-
// stays silent, and fired before adding the joiner so it isn't told about
|
|
361
|
-
// itself. See peerAttachedMessageSchema for the frame's payload rationale.
|
|
362
|
-
if (managed.clients.size > 0)
|
|
363
|
-
this.broadcast(managed, { type: "peer-attached" });
|
|
364
|
-
const coordinator = this.coordinatorForCwd(managed.session.cwd);
|
|
365
|
-
const client = {
|
|
366
|
-
ws,
|
|
367
|
-
pending: true,
|
|
368
|
-
pendingControl: [],
|
|
369
|
-
pendingBytes: [],
|
|
370
|
-
pendingTimer: null,
|
|
371
|
-
cols: 0,
|
|
372
|
-
rows: 0,
|
|
373
|
-
windowId,
|
|
374
|
-
coordinator,
|
|
375
|
-
compressMode: null,
|
|
376
|
-
brotliEncoder: null,
|
|
377
|
-
terminalResponder: false,
|
|
378
|
-
};
|
|
379
|
-
coordinator.add(ws);
|
|
380
|
-
managed.clients.add(client);
|
|
381
|
-
this.wsToClient.set(ws, { client, session: managed });
|
|
382
|
-
this.recomputeResize(managed);
|
|
383
|
-
// Seed a joiner with the current effective size when it's entering an
|
|
384
|
-
// already-multi-viewer session whose min its own (possibly wider) report
|
|
385
|
-
// doesn't change — recomputeResize only broadcasts on a change, so without
|
|
386
|
-
// this the new viewer would never learn it's constrained and would render
|
|
387
|
-
// no mask. A fresh spawn (now the lone viewer) has no stored size and is
|
|
388
|
-
// skipped, and a joiner that changes the min is reached by the broadcast.
|
|
389
|
-
if (managed.clients.size > 1 && managed.ptySizeCols !== null && managed.ptySizeRows !== null) {
|
|
390
|
-
this.sendToClient(client, {
|
|
391
|
-
type: "pty-size",
|
|
392
|
-
cols: managed.ptySizeCols,
|
|
393
|
-
rows: managed.ptySizeRows,
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
// Auto-promote a client that never sends {type:"ready"} — a back-compat
|
|
397
|
-
// client (an older bundled terminal, or any plain WS reader) would otherwise
|
|
398
|
-
// stay pending and never receive output. The localterm client sends ready
|
|
399
|
-
// within milliseconds of the session frame; the window is sized to clear a
|
|
400
|
-
// mobile/tailscale RTT (often DERP-relayed) so its {ready} lands first and
|
|
401
|
-
// its scrollback replay still lands before live fan-out. `promote` always
|
|
402
|
-
// sends `replay-end`, so even a slow link that auto-promotes with
|
|
403
|
-
// `replay: false` can't deadlock the client in its replay window.
|
|
404
|
-
client.pendingTimer = setTimeout(() => void this.promote(ws, false), this.pendingPromoteTimeoutMs);
|
|
405
|
-
client.pendingTimer.unref?.();
|
|
406
|
-
this.hooks.onSessionActivity();
|
|
407
|
-
return managed;
|
|
258
|
+
return this.clientHub.attach(ws, id, owner, windowId);
|
|
408
259
|
}
|
|
409
|
-
// Promote a pending client to live fan-out. Idempotent (a no-op once the
|
|
410
|
-
// client is already live), so the pending timeout and an explicit {ready}
|
|
411
|
-
// can race safely. When `replay` is true the session's scrollback ring
|
|
412
|
-
// buffer is sent as one binary frame first, then the control messages and
|
|
413
|
-
// output bytes buffered while pending flush in order — so a tab switching
|
|
414
|
-
// to this PTY lands on recent output instead of a blank screen, with no
|
|
415
|
-
// live frame interleaving between replay and the buffered fan-out.
|
|
416
|
-
//
|
|
417
|
-
// `replay-end` is sent on EVERY promote (not just `replay: true`): the
|
|
418
|
-
// localterm client opens its suppressed-replay window on the {session}
|
|
419
|
-
// frame — before its {ready} reaches us — so a slow link whose pending
|
|
420
|
-
// timeout auto-promotes with `replay: false` would otherwise never send the
|
|
421
|
-
// marker the client is waiting on, deadlocking it on a blank screen with
|
|
422
|
-
// every output frame buffered client-side. The auto-promote's `replay-end`
|
|
423
|
-
// lets the client flush whatever it buffered (the pending bytes that raced
|
|
424
|
-
// ahead of the marker) and rejoin live fan-out. A client that didn't open the
|
|
425
|
-
// window (a silent reattach, or a back-compat reader) treats it as a no-op.
|
|
426
260
|
async promote(ws, replay, compress = null) {
|
|
427
|
-
|
|
428
|
-
if (!entry)
|
|
429
|
-
return;
|
|
430
|
-
const client = entry.client;
|
|
431
|
-
if (!client.pending)
|
|
432
|
-
return;
|
|
433
|
-
if (client.pendingTimer !== null) {
|
|
434
|
-
clearTimeout(client.pendingTimer);
|
|
435
|
-
client.pendingTimer = null;
|
|
436
|
-
}
|
|
437
|
-
client.compressMode = compress;
|
|
438
|
-
this.ensureTerminalResponder(entry.session, client);
|
|
439
|
-
// Reset the persistent Brotli encoder: a new promote is a fresh attach (a
|
|
440
|
-
// PTY switch or reconnect), so the prior screen's LZ77 context is stale.
|
|
441
|
-
// The first frame of the new stream has no prior context (the per-frame
|
|
442
|
-
// equivalent); subsequent frames compress against it.
|
|
443
|
-
if (client.brotliEncoder !== null) {
|
|
444
|
-
client.brotliEncoder.release();
|
|
445
|
-
client.brotliEncoder = null;
|
|
446
|
-
}
|
|
447
|
-
if (compress === "br-ctx")
|
|
448
|
-
client.brotliEncoder = makeBrotliEncoder(WS_OUTPUT_BROTLI_QUALITY);
|
|
449
|
-
// Tell the client the chosen compress mode BEFORE the scrollback replay so
|
|
450
|
-
// it knows how to parse the compressed replay frames. A back-compat server
|
|
451
|
-
// that doesn't know "br-ctx" never sends this frame, so an old-server +
|
|
452
|
-
// new-client pair degrades to raw (no header) instead of mis-parsing.
|
|
453
|
-
this.sendControl(ws, { type: "compress", mode: compress });
|
|
454
|
-
if (replay) {
|
|
455
|
-
await this.sendScrollback(ws, entry.session, client);
|
|
456
|
-
}
|
|
457
|
-
// Tell the client the replay bytes have all landed so it can write them as
|
|
458
|
-
// one suppressed block (dropping xterm's responses to the stale query
|
|
459
|
-
// requests in the ring buffer). Sent on every promote — even when the
|
|
460
|
-
// snapshot was empty or replay wasn't requested — so the client always
|
|
461
|
-
// exits its suppressed-replay window and never deadlocks on a slow link.
|
|
462
|
-
this.sendControl(ws, { type: "replay-end" });
|
|
463
|
-
for (const payload of client.pendingControl)
|
|
464
|
-
this.sendControl(ws, payload);
|
|
465
|
-
for (const bytes of client.pendingBytes)
|
|
466
|
-
await this.sendOutputFrame(ws, bytes, client);
|
|
467
|
-
client.pendingControl = [];
|
|
468
|
-
client.pendingBytes = [];
|
|
469
|
-
client.pending = false;
|
|
470
|
-
// Re-push the ambient git-diff summary to the now-live client so a summary
|
|
471
|
-
// pushed while pending (and wiped by the client's cwd-driven null-reset on
|
|
472
|
-
// a cwd change during the pending window) is restored. See
|
|
473
|
-
// GitMetadataCoordinator.replayLastSummary.
|
|
474
|
-
client.coordinator?.replayLastSummary(ws);
|
|
261
|
+
return this.clientHub.promote(ws, replay, compress);
|
|
475
262
|
}
|
|
476
263
|
writeInput(ws, data) {
|
|
477
|
-
|
|
478
|
-
if (!entry)
|
|
479
|
-
return;
|
|
480
|
-
// A client sending input is clearly live and wants output. Promote it out
|
|
481
|
-
// of the pending hold without a scrollback replay — a back-compat client
|
|
482
|
-
// that never sends {type:"ready"} still unblocks on its first keystroke.
|
|
483
|
-
// The localterm client sends {type:"ready"} before any input, so this is a
|
|
484
|
-
// no-op for it. Promote flushes any buffered output before the input echoes.
|
|
485
|
-
if (entry.client.pending)
|
|
486
|
-
void this.promote(ws, false);
|
|
487
|
-
// Query replies follow the viewer that most recently drove the PTY.
|
|
488
|
-
this.assignTerminalResponder(entry.session, entry.client);
|
|
489
|
-
entry.session.session.write(data);
|
|
264
|
+
this.clientHub.writeInput(ws, data);
|
|
490
265
|
}
|
|
491
266
|
writeTerminalResponse(ws, data) {
|
|
492
|
-
|
|
493
|
-
if (!entry?.client.terminalResponder)
|
|
494
|
-
return;
|
|
495
|
-
this.writeInput(ws, data);
|
|
496
|
-
}
|
|
497
|
-
ensureTerminalResponder(managed, preferredClient) {
|
|
498
|
-
for (const client of managed.clients) {
|
|
499
|
-
if (client.terminalResponder)
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
const nextClient = preferredClient ??
|
|
503
|
-
Array.from(managed.clients).find((client) => !client.pending) ??
|
|
504
|
-
managed.clients.values().next().value;
|
|
505
|
-
if (nextClient)
|
|
506
|
-
this.assignTerminalResponder(managed, nextClient);
|
|
507
|
-
}
|
|
508
|
-
assignTerminalResponder(managed, responder) {
|
|
509
|
-
for (const client of managed.clients)
|
|
510
|
-
client.terminalResponder = client === responder;
|
|
267
|
+
this.clientHub.writeTerminalResponse(ws, data);
|
|
511
268
|
}
|
|
512
269
|
resize(ws, cols, rows, pixelWidth, pixelHeight) {
|
|
513
|
-
|
|
514
|
-
if (!entry)
|
|
515
|
-
return;
|
|
516
|
-
entry.client.cols = cols;
|
|
517
|
-
entry.client.rows = rows;
|
|
518
|
-
entry.client.pixelWidth = pixelWidth;
|
|
519
|
-
entry.client.pixelHeight = pixelHeight;
|
|
520
|
-
this.recomputeResize(entry.session);
|
|
270
|
+
this.clientHub.resize(ws, cols, rows, pixelWidth, pixelHeight);
|
|
521
271
|
}
|
|
522
|
-
// Detach a single client. If this was the last subscriber, arm the
|
|
523
|
-
// no-clients grace timer: the PTY stays alive for SESSION_GRACE_MS so a
|
|
524
|
-
// transient drop or a switch can re-attach, then is reaped if nobody does.
|
|
525
272
|
detach(ws) {
|
|
526
|
-
|
|
527
|
-
if (!entry)
|
|
528
|
-
return;
|
|
529
|
-
this.wsToClient.delete(ws);
|
|
530
|
-
const managed = entry.session;
|
|
531
|
-
const client = entry.client;
|
|
532
|
-
if (client.pendingTimer !== null) {
|
|
533
|
-
clearTimeout(client.pendingTimer);
|
|
534
|
-
client.pendingTimer = null;
|
|
535
|
-
}
|
|
536
|
-
if (client.brotliEncoder !== null) {
|
|
537
|
-
client.brotliEncoder.release();
|
|
538
|
-
client.brotliEncoder = null;
|
|
539
|
-
}
|
|
540
|
-
client.pendingControl = [];
|
|
541
|
-
client.pendingBytes = [];
|
|
542
|
-
if (client.coordinator) {
|
|
543
|
-
client.coordinator.remove(ws);
|
|
544
|
-
this.releaseCoordinator(client.coordinator);
|
|
545
|
-
}
|
|
546
|
-
managed.clients.delete(client);
|
|
547
|
-
if (client.terminalResponder)
|
|
548
|
-
this.ensureTerminalResponder(managed);
|
|
549
|
-
this.recomputeResize(managed);
|
|
550
|
-
if (managed.clients.size === 0 && !managed.session.isExited)
|
|
551
|
-
this.startGrace(managed);
|
|
552
|
-
this.hooks.onSessionActivity();
|
|
273
|
+
this.clientHub.detach(ws);
|
|
553
274
|
}
|
|
554
275
|
kill(id, owner = null) {
|
|
555
276
|
const managed = this.sessions.get(id);
|
|
@@ -572,7 +293,7 @@ export class SessionManager {
|
|
|
572
293
|
disposeAll() {
|
|
573
294
|
for (const managed of [...this.sessions.values()])
|
|
574
295
|
this.tearDown(managed);
|
|
575
|
-
this.
|
|
296
|
+
this.clientHub.dispose();
|
|
576
297
|
this.lastOutputAtByPid.clear();
|
|
577
298
|
}
|
|
578
299
|
// Spawn a PTY with no attached WebSocket — the REST/CLI path (POST /api/sessions,
|
|
@@ -588,8 +309,9 @@ export class SessionManager {
|
|
|
588
309
|
managed.pinned = pinned;
|
|
589
310
|
// A detached session has no client to detach from, so arm the grace now (a
|
|
590
311
|
// no-op arm when pinned — startGrace parks it indefinitely instead).
|
|
591
|
-
if (managed.clients.size === 0 && !managed.session.isExited)
|
|
592
|
-
this.startGrace(managed);
|
|
312
|
+
if (managed.clients.size === 0 && !managed.session.isExited) {
|
|
313
|
+
this.lifecyclePolicy.startGrace(managed);
|
|
314
|
+
}
|
|
593
315
|
return managed.id;
|
|
594
316
|
}
|
|
595
317
|
// Write input to a session by id — the REST/CLI send-keys path. Unlike the
|
|
@@ -622,9 +344,7 @@ export class SessionManager {
|
|
|
622
344
|
const managed = this.sessionFor(id, owner);
|
|
623
345
|
if (!managed)
|
|
624
346
|
return null;
|
|
625
|
-
|
|
626
|
-
const renderer = await this.ensureCaptureRenderer(managed);
|
|
627
|
-
return renderer.capture(capped);
|
|
347
|
+
return this.commandExecutor.capturePane(managed, lines);
|
|
628
348
|
}
|
|
629
349
|
// Toggle a session's pinned flag — PATCH /api/sessions/:id. Re-arms (or
|
|
630
350
|
// cancels) the grace timer live for a dormant session so the change takes
|
|
@@ -634,8 +354,9 @@ export class SessionManager {
|
|
|
634
354
|
if (!managed || (owner !== null && managed.owner !== owner))
|
|
635
355
|
return false;
|
|
636
356
|
managed.pinned = pinned;
|
|
637
|
-
if (managed.clients.size === 0 && !managed.session.isExited)
|
|
638
|
-
this.startGrace(managed);
|
|
357
|
+
if (managed.clients.size === 0 && !managed.session.isExited) {
|
|
358
|
+
this.lifecyclePolicy.startGrace(managed);
|
|
359
|
+
}
|
|
639
360
|
return true;
|
|
640
361
|
}
|
|
641
362
|
// Rename a session — PATCH /api/sessions/:id. The shell's next OSC title or
|
|
@@ -672,68 +393,7 @@ export class SessionManager {
|
|
|
672
393
|
const managed = this.sessionFor(id, owner);
|
|
673
394
|
if (!managed)
|
|
674
395
|
return null;
|
|
675
|
-
|
|
676
|
-
const startedAt = Date.now();
|
|
677
|
-
let resolved = false;
|
|
678
|
-
let lastChangeAt = Date.now();
|
|
679
|
-
let timeoutHandle = null;
|
|
680
|
-
let idleTimer = null;
|
|
681
|
-
return new Promise((resolve) => {
|
|
682
|
-
const finalize = async (matched) => {
|
|
683
|
-
if (resolved)
|
|
684
|
-
return;
|
|
685
|
-
resolved = true;
|
|
686
|
-
if (timeoutHandle)
|
|
687
|
-
clearTimeout(timeoutHandle);
|
|
688
|
-
if (idleTimer)
|
|
689
|
-
clearInterval(idleTimer);
|
|
690
|
-
session.off("output", onOutput);
|
|
691
|
-
session.off("exit", onExit);
|
|
692
|
-
const snapshot = await this.capturePane(id).catch(() => "");
|
|
693
|
-
resolve({
|
|
694
|
-
matched,
|
|
695
|
-
elapsedMs: Date.now() - startedAt,
|
|
696
|
-
snapshot: snapshot ?? "",
|
|
697
|
-
});
|
|
698
|
-
};
|
|
699
|
-
const testPredicate = async () => {
|
|
700
|
-
const renderer = await this.ensureCaptureRenderer(managed);
|
|
701
|
-
await renderer.flush();
|
|
702
|
-
return predicate.test(renderer.capture());
|
|
703
|
-
};
|
|
704
|
-
const onOutput = () => {
|
|
705
|
-
lastChangeAt = Date.now();
|
|
706
|
-
void testPredicate().then((hit) => {
|
|
707
|
-
if (hit && !resolved)
|
|
708
|
-
finalize(true);
|
|
709
|
-
});
|
|
710
|
-
};
|
|
711
|
-
const onExit = () => {
|
|
712
|
-
void finalize(false);
|
|
713
|
-
};
|
|
714
|
-
// Idle mode: resolve once no output has arrived for `idleMs`. The interval
|
|
715
|
-
// checks recency without forcing a renderer read each tick (the output
|
|
716
|
-
// listener already bumps lastChangeAt).
|
|
717
|
-
if (predicate.kind === "idle") {
|
|
718
|
-
idleTimer = setInterval(() => {
|
|
719
|
-
if (!resolved && Date.now() - lastChangeAt >= (idleMs ?? 0))
|
|
720
|
-
finalize(true);
|
|
721
|
-
}, WAIT_IDLE_POLL_INTERVAL_MS);
|
|
722
|
-
idleTimer.unref?.();
|
|
723
|
-
}
|
|
724
|
-
else {
|
|
725
|
-
// Text/regex: test once up front in case the pane already matches, then
|
|
726
|
-
// react to output events.
|
|
727
|
-
void testPredicate().then((hit) => {
|
|
728
|
-
if (hit && !resolved)
|
|
729
|
-
finalize(true);
|
|
730
|
-
});
|
|
731
|
-
}
|
|
732
|
-
session.on("output", onOutput);
|
|
733
|
-
session.on("exit", onExit);
|
|
734
|
-
timeoutHandle = setTimeout(() => finalize(false), timeoutMs);
|
|
735
|
-
timeoutHandle.unref?.();
|
|
736
|
-
});
|
|
396
|
+
return this.commandExecutor.waitFor(managed, predicate, timeoutMs, idleMs);
|
|
737
397
|
}
|
|
738
398
|
// Resolve the (col, row) of a label on the session's visible viewport — the
|
|
739
399
|
// `mouse --on-text` coord source. Reads the capture renderer's grid (the same
|
|
@@ -743,9 +403,7 @@ export class SessionManager {
|
|
|
743
403
|
const managed = this.sessionFor(id, owner);
|
|
744
404
|
if (!managed)
|
|
745
405
|
return null;
|
|
746
|
-
|
|
747
|
-
await renderer.flush();
|
|
748
|
-
return renderer.findTextInViewport(needle);
|
|
406
|
+
return this.commandExecutor.findTextInViewport(managed, needle);
|
|
749
407
|
}
|
|
750
408
|
// Whether the session's foreground app enabled a mouse tracking mode — gates
|
|
751
409
|
// the SGR-1006 fallback for `mouse` when no CDP tab is available.
|
|
@@ -763,22 +421,6 @@ export class SessionManager {
|
|
|
763
421
|
return { cols: 0, rows: 0 };
|
|
764
422
|
return { cols: managed.session.cols, rows: managed.session.rows };
|
|
765
423
|
}
|
|
766
|
-
// Lazily create (and prime) a session's capture renderer. Fed the scrollback
|
|
767
|
-
// snapshot at creation so it catches up on recent history before the renderer
|
|
768
|
-
// existed, then kept alive and fed live output by onSessionOutput. Awaits the
|
|
769
|
-
// snapshot's async parse so the first capture-pane read lands on a populated
|
|
770
|
-
// grid instead of a blank one (xterm parses `write` on a timer).
|
|
771
|
-
async ensureCaptureRenderer(managed) {
|
|
772
|
-
if (managed.captureRenderer)
|
|
773
|
-
return managed.captureRenderer;
|
|
774
|
-
const renderer = new CaptureRenderer(managed.session.cols, managed.session.rows);
|
|
775
|
-
const snapshot = managed.session.snapshotScrollback();
|
|
776
|
-
if (snapshot)
|
|
777
|
-
renderer.write(snapshot);
|
|
778
|
-
await renderer.flush();
|
|
779
|
-
managed.captureRenderer = renderer;
|
|
780
|
-
return renderer;
|
|
781
|
-
}
|
|
782
424
|
// Run a single shell command line inside a persistent session, capture its
|
|
783
425
|
// rendered output, and return its exit code — the blocking tmux send-keys +
|
|
784
426
|
// capture-pane replacement for agents. The command and its completion marker
|
|
@@ -792,432 +434,20 @@ export class SessionManager {
|
|
|
792
434
|
const managed = this.sessionFor(id, owner);
|
|
793
435
|
if (!managed)
|
|
794
436
|
return null;
|
|
795
|
-
|
|
796
|
-
const timeoutMs = this.clampInt(options.timeoutMs ?? EXEC_DEFAULT_TIMEOUT_MS, 1, EXEC_MAX_TIMEOUT_MS);
|
|
797
|
-
const outputLimit = this.clampInt(options.outputLimitBytes ?? EXEC_DEFAULT_OUTPUT_LIMIT_BYTES, 1, EXEC_MAX_OUTPUT_LIMIT_BYTES);
|
|
798
|
-
const token = randomBytes(8).toString("hex");
|
|
799
|
-
const startMarker = `__LT_S_${token}__`;
|
|
800
|
-
const endMarkerPrefix = `__LT_E_${token}__`;
|
|
801
|
-
const endPattern = new RegExp(`${endMarkerPrefix} (\\d+)`);
|
|
802
|
-
const cmd = command.trim() || ":";
|
|
803
|
-
const wrapped = `printf '${startMarker}\\n'; ${cmd}; printf '${endMarkerPrefix} %d\\n' "$?"`;
|
|
804
|
-
const startedAt = Date.now();
|
|
805
|
-
let accumulated = "";
|
|
806
|
-
let capped = false;
|
|
807
|
-
let exitCode = null;
|
|
808
|
-
let didTimeout = false;
|
|
809
|
-
let resolved = false;
|
|
810
|
-
let timeoutHandle = null;
|
|
811
|
-
let interruptHandle = null;
|
|
812
|
-
return new Promise((resolve) => {
|
|
813
|
-
const finalize = async (finalExit, finalTimedOut) => {
|
|
814
|
-
if (resolved)
|
|
815
|
-
return;
|
|
816
|
-
resolved = true;
|
|
817
|
-
if (timeoutHandle)
|
|
818
|
-
clearTimeout(timeoutHandle);
|
|
819
|
-
if (interruptHandle)
|
|
820
|
-
clearTimeout(interruptHandle);
|
|
821
|
-
session.off("output", onOutput);
|
|
822
|
-
session.off("exit", onExit);
|
|
823
|
-
resolve(await this.buildExecResult(session.cols, session.rows, accumulated, startMarker, endMarkerPrefix, finalExit, finalTimedOut, outputLimit, startedAt));
|
|
824
|
-
};
|
|
825
|
-
const onOutput = (data) => {
|
|
826
|
-
if (!capped) {
|
|
827
|
-
if (accumulated.length + data.length <= EXEC_RAW_ACCUMULATE_CAP_BYTES) {
|
|
828
|
-
accumulated += data;
|
|
829
|
-
}
|
|
830
|
-
else {
|
|
831
|
-
const room = EXEC_RAW_ACCUMULATE_CAP_BYTES - accumulated.length;
|
|
832
|
-
if (room > 0)
|
|
833
|
-
accumulated += data.slice(0, room);
|
|
834
|
-
capped = true;
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
// Once the timeout has fired we've committed to a timed-out result; a
|
|
838
|
-
// marker arriving during the interrupt grace (Ctrl-C kills the command,
|
|
839
|
-
// the trailing `printf END $?` runs with the interrupt exit code) is
|
|
840
|
-
// ignored so the call resolves as timed out, not as a normal completion.
|
|
841
|
-
if (didTimeout)
|
|
842
|
-
return;
|
|
843
|
-
const match = accumulated.match(endPattern);
|
|
844
|
-
if (match) {
|
|
845
|
-
exitCode = Number.parseInt(match[1], 10);
|
|
846
|
-
finalize(exitCode, false);
|
|
847
|
-
}
|
|
848
|
-
};
|
|
849
|
-
const onExit = (code) => {
|
|
850
|
-
exitCode = code;
|
|
851
|
-
finalize(code, false);
|
|
852
|
-
};
|
|
853
|
-
session.on("output", onOutput);
|
|
854
|
-
session.on("exit", onExit);
|
|
855
|
-
timeoutHandle = setTimeout(() => {
|
|
856
|
-
// Commit to a timed-out result: the command didn't finish within
|
|
857
|
-
// timeoutMs. Send Ctrl-C to interrupt it (so the session returns to a
|
|
858
|
-
// prompt for a follow-up call), then resolve after a short grace so any
|
|
859
|
-
// output already in flight is captured into the partial result. A marker
|
|
860
|
-
// arriving during the grace is ignored (see onOutput).
|
|
861
|
-
didTimeout = true;
|
|
862
|
-
session.write("\x03");
|
|
863
|
-
interruptHandle = setTimeout(() => finalize(null, true), EXEC_TIMEOUT_INTERRUPT_GRACE_MS);
|
|
864
|
-
interruptHandle.unref?.();
|
|
865
|
-
}, timeoutMs);
|
|
866
|
-
timeoutHandle.unref?.();
|
|
867
|
-
// A client sending input is live; for a detached session there's no
|
|
868
|
-
// pending handshake, so the bytes reach the PTY directly.
|
|
869
|
-
session.write(`${wrapped}\r`);
|
|
870
|
-
});
|
|
871
|
-
}
|
|
872
|
-
async buildExecResult(cols, rows, accumulated, startMarker, endMarkerPrefix, exitCode, timedOut, outputLimit, startedAt) {
|
|
873
|
-
// Render the captured raw stream through a fresh headless terminal and slice
|
|
874
|
-
// between the start/end marker rows for clean, ANSI-processed text. A fresh
|
|
875
|
-
// (not the persistent) renderer so this exec's output is isolated and the
|
|
876
|
-
// markers are always near the bottom of the buffer.
|
|
877
|
-
const renderer = new CaptureRenderer(cols, rows, EXEC_EPHEMERAL_SCROLLBACK);
|
|
878
|
-
let output;
|
|
879
|
-
try {
|
|
880
|
-
renderer.write(accumulated);
|
|
881
|
-
await renderer.flush();
|
|
882
|
-
const endRow = exitCode !== null && !timedOut ? renderer.findRow(`${endMarkerPrefix} ${exitCode}`) : -1;
|
|
883
|
-
const startRow = renderer.findRow(startMarker);
|
|
884
|
-
output = renderer.extractBetween(startRow, endRow);
|
|
885
|
-
}
|
|
886
|
-
finally {
|
|
887
|
-
renderer.dispose();
|
|
888
|
-
}
|
|
889
|
-
const textBytes = Buffer.byteLength(output, "utf8");
|
|
890
|
-
const truncated = textBytes > outputLimit;
|
|
891
|
-
if (truncated) {
|
|
892
|
-
output = Buffer.from(output, "utf8").subarray(0, outputLimit).toString("utf8");
|
|
893
|
-
}
|
|
894
|
-
return {
|
|
895
|
-
exitCode,
|
|
896
|
-
output,
|
|
897
|
-
timedOut,
|
|
898
|
-
truncated,
|
|
899
|
-
durationMs: Date.now() - startedAt,
|
|
900
|
-
};
|
|
901
|
-
}
|
|
902
|
-
clampInt(value, min, max) {
|
|
903
|
-
return Math.min(Math.max(Math.trunc(value), min), max);
|
|
904
|
-
}
|
|
905
|
-
async sendScrollback(ws, managed, client) {
|
|
906
|
-
const snapshot = managed.session.snapshotScrollback();
|
|
907
|
-
if (!snapshot)
|
|
908
|
-
return;
|
|
909
|
-
const bytes = Buffer.from(snapshot, "utf8");
|
|
910
|
-
for (let offset = 0; offset < bytes.byteLength; offset += MAX_OUTPUT_BYTES) {
|
|
911
|
-
await this.sendOutputFrame(ws, bytes.subarray(offset, offset + MAX_OUTPUT_BYTES), client);
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
sendOutputBytes(ws, bytes) {
|
|
915
|
-
if (ws.readyState !== WS_READY_STATE_OPEN)
|
|
916
|
-
return;
|
|
917
|
-
if (getBufferedAmount(ws) > WS_BACKPRESSURE_THRESHOLD_BYTES) {
|
|
918
|
-
ws.close(WS_CLOSE_BACKPRESSURE, "backpressure");
|
|
919
|
-
return;
|
|
920
|
-
}
|
|
921
|
-
try {
|
|
922
|
-
ws.send(bytes);
|
|
923
|
-
}
|
|
924
|
-
catch {
|
|
925
|
-
/* socket closed between readyState check and send */
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
compressPayload(bytes, mode) {
|
|
929
|
-
return mode === "br"
|
|
930
|
-
? zlib.brotliCompressSync(bytes, {
|
|
931
|
-
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: WS_OUTPUT_BROTLI_QUALITY },
|
|
932
|
-
})
|
|
933
|
-
: zlib.gzipSync(bytes, { level: WS_OUTPUT_GZIP_LEVEL });
|
|
934
|
-
}
|
|
935
|
-
frameWithHeader(header, payload) {
|
|
936
|
-
const out = Buffer.allocUnsafe(1 + payload.length);
|
|
937
|
-
out[0] = header;
|
|
938
|
-
out.set(payload, 1);
|
|
939
|
-
return out;
|
|
940
|
-
}
|
|
941
|
-
// 5-byte header for the context-takeover mode: 0x03 + 4-byte LE raw size, so
|
|
942
|
-
// the client can size-delimit a frame out of the persistent DecompressionStream
|
|
943
|
-
// (which doesn't end per frame and emits in arbitrary 16KB chunks).
|
|
944
|
-
frameWithCtxHeader(compressed, rawSize) {
|
|
945
|
-
const out = Buffer.allocUnsafe(WS_OUTPUT_CTX_HEADER_BYTES + compressed.length);
|
|
946
|
-
out[0] = WS_OUTPUT_BROTLI_CTX;
|
|
947
|
-
out.writeUInt32LE(rawSize, 1);
|
|
948
|
-
out.set(compressed, WS_OUTPUT_CTX_HEADER_BYTES);
|
|
949
|
-
return out;
|
|
950
|
-
}
|
|
951
|
-
async sendOutputFrame(ws, bytes, client) {
|
|
952
|
-
const mode = client.compressMode;
|
|
953
|
-
if (mode === null) {
|
|
954
|
-
this.sendOutputBytes(ws, bytes);
|
|
955
|
-
return;
|
|
956
|
-
}
|
|
957
|
-
if (bytes.length < WS_OUTPUT_COMPRESS_THRESHOLD_BYTES) {
|
|
958
|
-
this.sendOutputBytes(ws, this.frameWithHeader(WS_OUTPUT_RAW, bytes));
|
|
959
|
-
return;
|
|
960
|
-
}
|
|
961
|
-
if (mode === "br-ctx") {
|
|
962
|
-
const compressed = await client.brotliEncoder.flush(bytes);
|
|
963
|
-
this.sendOutputBytes(ws, this.frameWithCtxHeader(compressed, bytes.length));
|
|
964
|
-
return;
|
|
965
|
-
}
|
|
966
|
-
const compressed = this.compressPayload(bytes, mode);
|
|
967
|
-
this.sendOutputBytes(ws, this.frameWithHeader(mode === "br" ? WS_OUTPUT_BROTLI : WS_OUTPUT_GZIP, compressed));
|
|
968
|
-
}
|
|
969
|
-
broadcastBytes(managed, bytes) {
|
|
970
|
-
if (bytes.length === 0)
|
|
971
|
-
return;
|
|
972
|
-
const compressible = bytes.length >= WS_OUTPUT_COMPRESS_THRESHOLD_BYTES;
|
|
973
|
-
let brotli = null;
|
|
974
|
-
let gzip = null;
|
|
975
|
-
for (const client of managed.clients) {
|
|
976
|
-
if (client.pending) {
|
|
977
|
-
client.pendingBytes.push(bytes);
|
|
978
|
-
continue;
|
|
979
|
-
}
|
|
980
|
-
const mode = client.compressMode;
|
|
981
|
-
if (mode === null) {
|
|
982
|
-
this.sendOutputBytes(client.ws, bytes);
|
|
983
|
-
continue;
|
|
984
|
-
}
|
|
985
|
-
if (!compressible) {
|
|
986
|
-
this.sendOutputBytes(client.ws, this.frameWithHeader(WS_OUTPUT_RAW, bytes));
|
|
987
|
-
continue;
|
|
988
|
-
}
|
|
989
|
-
if (mode === "br-ctx") {
|
|
990
|
-
// Per-client persistent stream: the flush is async (chained per encoder
|
|
991
|
-
// in PTY order), so fire-and-forget here — the chain preserves order
|
|
992
|
-
// across this client's frames and sendOutputBytes checks
|
|
993
|
-
// readyState/backpressure at send time.
|
|
994
|
-
void client
|
|
995
|
-
.brotliEncoder.flush(bytes)
|
|
996
|
-
.then((compressed) => this.sendOutputBytes(client.ws, this.frameWithCtxHeader(compressed, bytes.length)));
|
|
997
|
-
continue;
|
|
998
|
-
}
|
|
999
|
-
if (mode === "br") {
|
|
1000
|
-
if (brotli === null)
|
|
1001
|
-
brotli = this.compressPayload(bytes, "br");
|
|
1002
|
-
this.sendOutputBytes(client.ws, this.frameWithHeader(WS_OUTPUT_BROTLI, brotli));
|
|
1003
|
-
}
|
|
1004
|
-
else {
|
|
1005
|
-
if (gzip === null)
|
|
1006
|
-
gzip = this.compressPayload(bytes, "gzip");
|
|
1007
|
-
this.sendOutputBytes(client.ws, this.frameWithHeader(WS_OUTPUT_GZIP, gzip));
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
sendToClient(client, payload) {
|
|
1012
|
-
if (client.pending) {
|
|
1013
|
-
client.pendingControl.push(payload);
|
|
1014
|
-
return;
|
|
1015
|
-
}
|
|
1016
|
-
this.sendControl(client.ws, payload);
|
|
1017
|
-
}
|
|
1018
|
-
broadcast(managed, payload) {
|
|
1019
|
-
for (const client of managed.clients)
|
|
1020
|
-
this.sendToClient(client, payload);
|
|
1021
|
-
}
|
|
1022
|
-
// Fan a control message out to every client currently viewing any session
|
|
1023
|
-
// owned by the same identity as `managed`, not just `managed`'s own viewers.
|
|
1024
|
-
// Used for notifications so a user who stepped away to another session still
|
|
1025
|
-
// gets the ping; owner-scoped so a notification never crosses an identity
|
|
1026
|
-
// boundary (a non-operator client can only be attached to an owner-matching
|
|
1027
|
-
// session, so matching session.owner === managed.owner is the partition).
|
|
1028
|
-
broadcastToOwner(managed, payload) {
|
|
1029
|
-
for (const { client, session } of this.wsToClient.values()) {
|
|
1030
|
-
if (session.owner === managed.owner)
|
|
1031
|
-
this.sendToClient(client, payload);
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
onSessionOutput(managed, data) {
|
|
1035
|
-
const didEndSynchronizedOutput = managed.synchronizedOutputEndDetector.push(data);
|
|
1036
|
-
managed.outputBatch += data;
|
|
1037
|
-
managed.lastOutputAt = Date.now();
|
|
1038
|
-
if (managed.automation)
|
|
1039
|
-
this.appendAutomationLog(managed, data);
|
|
1040
|
-
this.noteOutput(managed.session.pid);
|
|
1041
|
-
this.hooks.onOutputActivity();
|
|
1042
|
-
// Keep the capture renderer (if one exists) in lockstep with the PTY so a
|
|
1043
|
-
// later capture-pane reads current rendered text. Lazily created, so this
|
|
1044
|
-
// is a no-op for sessions nobody has captured (the common browser case).
|
|
1045
|
-
managed.captureRenderer?.write(data);
|
|
1046
|
-
// DEC synchronized output supplies the exact safe redraw boundary. Flush
|
|
1047
|
-
// when DECRST 2026 arrives instead of waiting for the idle fallback, while
|
|
1048
|
-
// unsynchronized output keeps the existing anti-flicker window unchanged.
|
|
1049
|
-
if (didEndSynchronizedOutput || managed.outputBatch.length >= OUTPUT_BATCH_FLUSH_BYTES) {
|
|
1050
|
-
if (managed.outputBatchTimer !== null) {
|
|
1051
|
-
clearTimeout(managed.outputBatchTimer);
|
|
1052
|
-
managed.outputBatchTimer = null;
|
|
1053
|
-
}
|
|
1054
|
-
this.flushOutput(managed);
|
|
1055
|
-
return;
|
|
1056
|
-
}
|
|
1057
|
-
// Without a synchronized-output boundary, reset the coalescing window on
|
|
1058
|
-
// every chunk so the flush lands OUTPUT_BATCH_WINDOW_MS after the LAST
|
|
1059
|
-
// chunk of a burst, not a fixed window after the first. A full-screen TUI
|
|
1060
|
-
// redraw of a large session emits across more than the window (node-pty
|
|
1061
|
-
// delivers it as many 1024-byte data events over successive event-loop
|
|
1062
|
-
// turns); a one-shot window flushed mid-redraw and split the frame across
|
|
1063
|
-
// multiple WebSocket messages. Over a bandwidth-limited link each split
|
|
1064
|
-
// arrives as its own atomic message and xterm paints it separately — the
|
|
1065
|
-
// visible top-to-bottom crawl. A resetting window holds the whole burst until the
|
|
1066
|
-
// PTY goes idle, then sends one message; the browser receives it atomically
|
|
1067
|
-
// and xterm renders it in a single paint regardless of link bandwidth.
|
|
1068
|
-
// Sustained high-throughput output never idles, so OUTPUT_BATCH_FLUSH_BYTES
|
|
1069
|
-
// still gates the message rate there (unchanged).
|
|
1070
|
-
if (managed.outputBatchTimer !== null) {
|
|
1071
|
-
managed.outputBatchTimer.refresh();
|
|
1072
|
-
return;
|
|
1073
|
-
}
|
|
1074
|
-
managed.outputBatchTimer = setTimeout(() => {
|
|
1075
|
-
managed.outputBatchTimer = null;
|
|
1076
|
-
this.flushOutput(managed);
|
|
1077
|
-
}, OUTPUT_BATCH_WINDOW_MS);
|
|
1078
|
-
managed.outputBatchTimer.unref?.();
|
|
1079
|
-
}
|
|
1080
|
-
// Accumulate ANSI-stripped PTY output for an automation shell run, keeping
|
|
1081
|
-
// the tail within the log cap so a long command's final output survives.
|
|
1082
|
-
appendAutomationLog(managed, data) {
|
|
1083
|
-
const stripped = stripAnsi(data);
|
|
1084
|
-
if (stripped.length === 0)
|
|
1085
|
-
return;
|
|
1086
|
-
const combined = managed.automationLog + stripped;
|
|
1087
|
-
if (combined.length <= MAX_AUTOMATION_LOG_LENGTH) {
|
|
1088
|
-
managed.automationLog = combined;
|
|
1089
|
-
return;
|
|
1090
|
-
}
|
|
1091
|
-
const overflow = combined.length - MAX_AUTOMATION_LOG_LENGTH;
|
|
1092
|
-
managed.automationLog = combined.slice(overflow);
|
|
1093
|
-
}
|
|
1094
|
-
flushOutput(managed) {
|
|
1095
|
-
const batch = managed.outputBatch;
|
|
1096
|
-
managed.outputBatch = "";
|
|
1097
|
-
if (!batch)
|
|
1098
|
-
return;
|
|
1099
|
-
const bytes = Buffer.from(batch, "utf8");
|
|
1100
|
-
if (bytes.byteLength <= MAX_OUTPUT_BYTES) {
|
|
1101
|
-
this.broadcastBytes(managed, bytes);
|
|
1102
|
-
}
|
|
1103
|
-
else {
|
|
1104
|
-
for (let offset = 0; offset < bytes.byteLength; offset += MAX_OUTPUT_BYTES) {
|
|
1105
|
-
this.broadcastBytes(managed, bytes.subarray(offset, offset + MAX_OUTPUT_BYTES));
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
this.maybePauseAfterFlush(managed);
|
|
1109
|
-
}
|
|
1110
|
-
maybePauseAfterFlush(managed) {
|
|
1111
|
-
if (managed.session.isPaused)
|
|
1112
|
-
return;
|
|
1113
|
-
for (const client of managed.clients) {
|
|
1114
|
-
if (client.pending)
|
|
1115
|
-
continue;
|
|
1116
|
-
if (getBufferedAmount(client.ws) >= WS_OUTBOUND_PAUSE_HIGH_WATER_BYTES) {
|
|
1117
|
-
managed.session.pause();
|
|
1118
|
-
this.ensureDrainPoll(managed);
|
|
1119
|
-
return;
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
ensureDrainPoll(managed) {
|
|
1124
|
-
if (managed.drainPollTimer !== null)
|
|
1125
|
-
return;
|
|
1126
|
-
managed.drainPollTimer = setInterval(() => {
|
|
1127
|
-
if (!managed.session.isPaused) {
|
|
1128
|
-
this.stopDrainPoll(managed);
|
|
1129
|
-
return;
|
|
1130
|
-
}
|
|
1131
|
-
let allLow = true;
|
|
1132
|
-
for (const client of managed.clients) {
|
|
1133
|
-
if (client.pending)
|
|
1134
|
-
continue;
|
|
1135
|
-
if (getBufferedAmount(client.ws) > WS_OUTBOUND_RESUME_LOW_WATER_BYTES) {
|
|
1136
|
-
allLow = false;
|
|
1137
|
-
break;
|
|
1138
|
-
}
|
|
1139
|
-
}
|
|
1140
|
-
if (allLow) {
|
|
1141
|
-
managed.session.resume();
|
|
1142
|
-
this.stopDrainPoll(managed);
|
|
1143
|
-
}
|
|
1144
|
-
}, WS_OUTBOUND_DRAIN_POLL_MS);
|
|
1145
|
-
managed.drainPollTimer.unref?.();
|
|
1146
|
-
}
|
|
1147
|
-
stopDrainPoll(managed) {
|
|
1148
|
-
if (managed.drainPollTimer === null)
|
|
1149
|
-
return;
|
|
1150
|
-
clearInterval(managed.drainPollTimer);
|
|
1151
|
-
managed.drainPollTimer = null;
|
|
1152
|
-
}
|
|
1153
|
-
recomputeResize(managed) {
|
|
1154
|
-
const session = managed.session;
|
|
1155
|
-
if (session.isExited)
|
|
1156
|
-
return;
|
|
1157
|
-
let cols = Infinity;
|
|
1158
|
-
let rows = Infinity;
|
|
1159
|
-
let single = null;
|
|
1160
|
-
let count = 0;
|
|
1161
|
-
for (const client of managed.clients) {
|
|
1162
|
-
count++;
|
|
1163
|
-
single = client;
|
|
1164
|
-
if (client.cols > 0 && client.cols < cols)
|
|
1165
|
-
cols = client.cols;
|
|
1166
|
-
if (client.rows > 0 && client.rows < rows)
|
|
1167
|
-
rows = client.rows;
|
|
1168
|
-
}
|
|
1169
|
-
if (count === 0)
|
|
1170
|
-
return;
|
|
1171
|
-
if (!Number.isFinite(cols) || !Number.isFinite(rows))
|
|
1172
|
-
return;
|
|
1173
|
-
if (count === 1 && single?.pixelWidth !== undefined && single?.pixelHeight !== undefined) {
|
|
1174
|
-
session.resize(cols, rows, single.pixelWidth, single.pixelHeight);
|
|
1175
|
-
}
|
|
1176
|
-
else {
|
|
1177
|
-
session.resize(cols, rows);
|
|
1178
|
-
}
|
|
1179
|
-
// Keep the capture renderer's grid at the PTY's effective size so a
|
|
1180
|
-
// capture-pane reflects the same line wrapping a viewer would see.
|
|
1181
|
-
managed.captureRenderer?.resize(cols, rows);
|
|
1182
|
-
// The PTY's effective size is the min across attached clients (tmux-style):
|
|
1183
|
-
// a narrower peer constrains everyone. Broadcast it on change so each
|
|
1184
|
-
// viewer can mask the dead area beyond its own (possibly wider) grid as
|
|
1185
|
-
// inactive chrome. A lone viewer is never constrained (its effective size
|
|
1186
|
-
// always equals its own), so it's left quiet except for one clear frame when
|
|
1187
|
-
// a peer detaches and drops it back to solo — that erases the mask the
|
|
1188
|
-
// leaving peer had imposed. A joiner entering an already-constrained
|
|
1189
|
-
// session without changing the min is seeded in attach.
|
|
1190
|
-
const sizeChanged = managed.ptySizeCols !== cols || managed.ptySizeRows !== rows;
|
|
1191
|
-
managed.ptySizeCols = cols;
|
|
1192
|
-
managed.ptySizeRows = rows;
|
|
1193
|
-
if (count > 1) {
|
|
1194
|
-
managed.ptySizeWasMultiViewer = true;
|
|
1195
|
-
if (sizeChanged) {
|
|
1196
|
-
this.broadcast(managed, { type: "pty-size", cols, rows });
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
else if (managed.ptySizeWasMultiViewer) {
|
|
1200
|
-
managed.ptySizeWasMultiViewer = false;
|
|
1201
|
-
this.broadcast(managed, { type: "pty-size", cols, rows });
|
|
1202
|
-
}
|
|
437
|
+
return this.commandExecutor.execute(managed, command, options);
|
|
1203
438
|
}
|
|
1204
439
|
installSessionListeners(managed) {
|
|
1205
440
|
const session = managed.session;
|
|
1206
|
-
session.on("output", (data) => this.onSessionOutput(managed, data));
|
|
1207
|
-
session.on("title", (title) => this.broadcast(managed, { type: "title", title }));
|
|
441
|
+
session.on("output", (data) => this.outputCoordinator.onSessionOutput(managed, data));
|
|
442
|
+
session.on("title", (title) => this.outputTransport.broadcast(managed, { type: "title", title }));
|
|
1208
443
|
session.on("cwd", (cwd) => {
|
|
1209
|
-
this.broadcast(managed, { type: "cwd", cwd });
|
|
1210
|
-
|
|
1211
|
-
managed.gitWatcher.start(cwd);
|
|
1212
|
-
for (const client of managed.clients)
|
|
1213
|
-
this.moveClientCoordinator(client, cwd);
|
|
1214
|
-
if (!managed.automation)
|
|
1215
|
-
this.hooks.onSessionEvent("cwd", cwd);
|
|
444
|
+
this.outputTransport.broadcast(managed, { type: "cwd", cwd });
|
|
445
|
+
this.gitEventBridge.handleCwdChange(managed, cwd);
|
|
1216
446
|
});
|
|
1217
447
|
session.on("foreground", (process) => {
|
|
1218
448
|
managed.hasForeground = process !== null;
|
|
1219
449
|
managed.foregroundName = process;
|
|
1220
|
-
this.broadcast(managed, { type: "foreground", process });
|
|
450
|
+
this.outputTransport.broadcast(managed, { type: "foreground", process });
|
|
1221
451
|
this.hooks.onSessionActivity();
|
|
1222
452
|
if (!managed.automation && session.lastEmittedCwd) {
|
|
1223
453
|
this.hooks.onSessionEvent("foreground", session.lastEmittedCwd);
|
|
@@ -1229,7 +459,7 @@ export class SessionManager {
|
|
|
1229
459
|
// lets each receiving tab suppress the notification when the session is
|
|
1230
460
|
// already viewed in another browser profile, and tells the SW's click
|
|
1231
461
|
// handler whether to open a fresh tab (orphaned) or not (avoid a duplicate).
|
|
1232
|
-
this.broadcastToOwner(managed, {
|
|
462
|
+
this.clientHub.broadcastToOwner(managed, {
|
|
1233
463
|
type: "notification",
|
|
1234
464
|
sessionId: managed.id,
|
|
1235
465
|
body,
|
|
@@ -1239,48 +469,16 @@ export class SessionManager {
|
|
|
1239
469
|
this.hooks.onSessionEvent("notification", session.lastEmittedCwd);
|
|
1240
470
|
}
|
|
1241
471
|
});
|
|
1242
|
-
|
|
1243
|
-
const cwd = session.lastEmittedCwd;
|
|
1244
|
-
if (cwd)
|
|
1245
|
-
this.coordinatorForCwd(cwd).signal();
|
|
1246
|
-
if (!managed.automation && cwd)
|
|
1247
|
-
this.hooks.onSessionEvent("git-dirty", cwd);
|
|
1248
|
-
});
|
|
472
|
+
this.gitEventBridge.installSessionListener(managed);
|
|
1249
473
|
session.on("exit", (code) => this.handleExit(managed, code));
|
|
1250
474
|
const automation = managed.automation;
|
|
1251
475
|
if (automation) {
|
|
1252
476
|
session.on("automation-exit", (exitCode) => this.hooks.onAutomationExit(automation.automationId, automation.runId, exitCode, managed.automationLog));
|
|
1253
477
|
}
|
|
1254
|
-
|
|
1255
|
-
const cwd = session.lastEmittedCwd;
|
|
1256
|
-
if (cwd)
|
|
1257
|
-
this.coordinatorForCwd(cwd).signal();
|
|
1258
|
-
});
|
|
1259
|
-
for (const refEvent of GIT_DIFF_WATCHER_EVENT_NAMES) {
|
|
1260
|
-
if (refEvent === "git-dirty")
|
|
1261
|
-
continue;
|
|
1262
|
-
const eventName = refEvent;
|
|
1263
|
-
managed.gitWatcher.on(eventName, () => {
|
|
1264
|
-
if (!managed.automation && session.lastEmittedCwd) {
|
|
1265
|
-
this.hooks.onSessionEvent(eventName, session.lastEmittedCwd);
|
|
1266
|
-
}
|
|
1267
|
-
});
|
|
1268
|
-
}
|
|
1269
|
-
managed.gitWatcher.start(session.cwd);
|
|
1270
|
-
}
|
|
1271
|
-
moveClientCoordinator(client, cwd) {
|
|
1272
|
-
const next = this.coordinatorForCwd(cwd);
|
|
1273
|
-
if (client.coordinator === next)
|
|
1274
|
-
return;
|
|
1275
|
-
if (client.coordinator) {
|
|
1276
|
-
client.coordinator.remove(client.ws);
|
|
1277
|
-
this.releaseCoordinator(client.coordinator);
|
|
1278
|
-
}
|
|
1279
|
-
client.coordinator = next;
|
|
1280
|
-
next.add(client.ws);
|
|
478
|
+
this.gitEventBridge.installWatcher(managed);
|
|
1281
479
|
}
|
|
1282
480
|
handleExit(managed, code) {
|
|
1283
|
-
this.flushOutput(managed);
|
|
481
|
+
this.outputCoordinator.flushOutput(managed);
|
|
1284
482
|
for (const client of managed.clients) {
|
|
1285
483
|
this.hooks.onClientExit(client.ws, code);
|
|
1286
484
|
this.sendControl(client.ws, { type: "exit", code });
|
|
@@ -1298,11 +496,7 @@ export class SessionManager {
|
|
|
1298
496
|
this.hooks.onSessionActivity();
|
|
1299
497
|
}
|
|
1300
498
|
tearDown(managed) {
|
|
1301
|
-
|
|
1302
|
-
clearTimeout(managed.graceTimer);
|
|
1303
|
-
managed.graceTimer = null;
|
|
1304
|
-
}
|
|
1305
|
-
managed.parkedAt = null;
|
|
499
|
+
this.lifecyclePolicy.cancelGrace(managed);
|
|
1306
500
|
managed.captureRenderer?.dispose();
|
|
1307
501
|
managed.captureRenderer = undefined;
|
|
1308
502
|
deletePasteImagesForSession(managed.id);
|
|
@@ -1310,22 +504,9 @@ export class SessionManager {
|
|
|
1310
504
|
clearTimeout(managed.outputBatchTimer);
|
|
1311
505
|
managed.outputBatchTimer = null;
|
|
1312
506
|
}
|
|
1313
|
-
this.stopDrainPoll(managed);
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
if (client.pendingTimer !== null) {
|
|
1317
|
-
clearTimeout(client.pendingTimer);
|
|
1318
|
-
client.pendingTimer = null;
|
|
1319
|
-
}
|
|
1320
|
-
client.pendingControl = [];
|
|
1321
|
-
client.pendingBytes = [];
|
|
1322
|
-
if (client.coordinator) {
|
|
1323
|
-
client.coordinator.remove(client.ws);
|
|
1324
|
-
this.releaseCoordinator(client.coordinator);
|
|
1325
|
-
}
|
|
1326
|
-
this.wsToClient.delete(client.ws);
|
|
1327
|
-
}
|
|
1328
|
-
managed.clients.clear();
|
|
507
|
+
this.outputCoordinator.stopDrainPoll(managed);
|
|
508
|
+
this.gitEventBridge.dispose(managed);
|
|
509
|
+
this.clientHub.tearDown(managed);
|
|
1329
510
|
this.sessions.delete(managed.id);
|
|
1330
511
|
this.lastOutputAtByPid.delete(managed.session.pid);
|
|
1331
512
|
try {
|
|
@@ -1335,114 +516,18 @@ export class SessionManager {
|
|
|
1335
516
|
/* already torn down */
|
|
1336
517
|
}
|
|
1337
518
|
}
|
|
1338
|
-
evictOldestDormant() {
|
|
1339
|
-
let oldest = null;
|
|
1340
|
-
let oldestKey = Infinity;
|
|
1341
|
-
for (const managed of this.sessions.values()) {
|
|
1342
|
-
if (managed.clients.size > 0)
|
|
1343
|
-
continue;
|
|
1344
|
-
// Pinned sessions are never silently evicted — they're explicitly held.
|
|
1345
|
-
if (managed.pinned)
|
|
1346
|
-
continue;
|
|
1347
|
-
// Evict the parked session whose grace fires soonest (armed earliest); a
|
|
1348
|
-
// parked session with no timer is a fresh spawn nobody attached yet —
|
|
1349
|
-
// yield it only after all armed ones.
|
|
1350
|
-
const key = managed.parkedAt ?? managed.createdAt;
|
|
1351
|
-
if (key < oldestKey) {
|
|
1352
|
-
oldestKey = key;
|
|
1353
|
-
oldest = managed;
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1356
|
-
if (oldest)
|
|
1357
|
-
this.tearDown(oldest);
|
|
1358
|
-
}
|
|
1359
|
-
startGrace(managed) {
|
|
1360
|
-
this.cancelGrace(managed);
|
|
1361
|
-
managed.parkedAt = Date.now();
|
|
1362
|
-
// Pinned sessions park indefinitely — never reaped by the idle grace and
|
|
1363
|
-
// never evicted at the cap. They live until an explicit kill or shell exit.
|
|
1364
|
-
if (managed.pinned)
|
|
1365
|
-
return;
|
|
1366
|
-
const graceMs = this.getGraceMs();
|
|
1367
|
-
// "Never reap": park the shell with no timer. It lingers until a viewer
|
|
1368
|
-
// reattaches, it's killed from the switcher, the shell exits, or it's
|
|
1369
|
-
// evicted at MAX_CONCURRENT_SESSIONS. parkedAt stays set so eviction
|
|
1370
|
-
// ordering still treats it as dormant.
|
|
1371
|
-
if (graceMs === null)
|
|
1372
|
-
return;
|
|
1373
|
-
managed.graceTimer = setTimeout(() => {
|
|
1374
|
-
managed.graceTimer = null;
|
|
1375
|
-
managed.parkedAt = null;
|
|
1376
|
-
// Re-check on fire: reschedule while the shell is still doing something —
|
|
1377
|
-
// output still arriving (running), or a foreground program still alive
|
|
1378
|
-
// though quiet (alive-quiet) — so a closed tab never kills a running
|
|
1379
|
-
// command mid-stream, even after it's gone quiet. The shell only dies on
|
|
1380
|
-
// a real idle (ready: no recent output and no foreground program, no
|
|
1381
|
-
// clients), the same "no activity" signal that turns the tab's favicon
|
|
1382
|
-
// grey.
|
|
1383
|
-
if (this.computeState(managed) !== "ready") {
|
|
1384
|
-
this.startGrace(managed);
|
|
1385
|
-
return;
|
|
1386
|
-
}
|
|
1387
|
-
this.tearDown(managed);
|
|
1388
|
-
this.hooks.onSessionActivity();
|
|
1389
|
-
}, graceMs);
|
|
1390
|
-
managed.graceTimer.unref?.();
|
|
1391
|
-
}
|
|
1392
519
|
// Re-arm every parked session's grace timer with the current grace value.
|
|
1393
520
|
// Called after a `PUT /api/config` grace change so it takes effect on shells
|
|
1394
521
|
// that are already dormant, not only the next detach: a finite value arms (or
|
|
1395
522
|
// resets) their timers, and `null` cancels them so they park indefinitely.
|
|
1396
523
|
rearmGrace() {
|
|
1397
|
-
|
|
1398
|
-
if (managed.clients.size === 0 && !managed.session.isExited)
|
|
1399
|
-
this.startGrace(managed);
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
cancelGrace(managed) {
|
|
1403
|
-
if (managed.graceTimer !== null) {
|
|
1404
|
-
clearTimeout(managed.graceTimer);
|
|
1405
|
-
managed.graceTimer = null;
|
|
1406
|
-
}
|
|
1407
|
-
managed.parkedAt = null;
|
|
1408
|
-
}
|
|
1409
|
-
// The favicon-equivalent state, computed from the same signals the client's
|
|
1410
|
-
// favicon uses (recent output → running; a foreground program but quiet →
|
|
1411
|
-
// alive-quiet; idle → ready). Surfaced on the session list so the row icon
|
|
1412
|
-
// colors match the tab the user is looking at.
|
|
1413
|
-
computeState(managed) {
|
|
1414
|
-
if (Date.now() - managed.lastOutputAt < SESSION_ACTIVITY_WINDOW_MS)
|
|
1415
|
-
return "running";
|
|
1416
|
-
return managed.hasForeground ? "alive-quiet" : "ready";
|
|
1417
|
-
}
|
|
1418
|
-
coordinatorForCwd(cwd) {
|
|
1419
|
-
const key = path.resolve(cwd);
|
|
1420
|
-
let coordinator = this.coordinatorsByCwd.get(key);
|
|
1421
|
-
if (!coordinator) {
|
|
1422
|
-
coordinator = new GitMetadataCoordinator(key, this.sendControl);
|
|
1423
|
-
this.coordinatorsByCwd.set(key, coordinator);
|
|
1424
|
-
}
|
|
1425
|
-
return coordinator;
|
|
1426
|
-
}
|
|
1427
|
-
releaseCoordinator(coordinator) {
|
|
1428
|
-
if (coordinator.isEmpty)
|
|
1429
|
-
this.coordinatorsByCwd.delete(coordinator.cwd);
|
|
524
|
+
this.lifecyclePolicy.rearmGrace(this.sessions);
|
|
1430
525
|
}
|
|
1431
|
-
// Push a freshly-detected PR to every tab in `cwd` after the
|
|
1432
|
-
// /api/git/branches/pr endpoint recomputes it, so a remote state change one
|
|
1433
|
-
// tab observed (a merge on GitHub) reaches siblings sharing the directory.
|
|
1434
|
-
// Non-creating: a cwd with no subscribers has no coordinator, and allocating
|
|
1435
|
-
// one here would orphan it (it never enters the attach/detach release path).
|
|
1436
526
|
broadcastGitBranchPr(cwd, pr) {
|
|
1437
|
-
this.
|
|
527
|
+
this.gitEventBridge.broadcastGitBranchPr(cwd, pr);
|
|
1438
528
|
}
|
|
1439
|
-
// Whether any tab is currently subscribed to the per-cwd coordinator — i.e.
|
|
1440
|
-
// there is a live audience for a PR lease refresh. Used to gate the
|
|
1441
|
-
// gh-activity refresh so a `gh` invocation in a cwd nobody is viewing never
|
|
1442
|
-
// triggers a pointless GitHub API call (mirrors broadcastGitBranchPr's
|
|
1443
|
-
// non-creating philosophy).
|
|
1444
529
|
hasCoordinatorFor(cwd) {
|
|
1445
|
-
return this.
|
|
530
|
+
return this.gitEventBridge.hasCoordinatorFor(cwd);
|
|
1446
531
|
}
|
|
1447
532
|
}
|
|
1448
533
|
//# sourceMappingURL=session-manager.js.map
|