@monotykamary/localterm-server 2.66.8 → 2.66.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/dist/agent-git-status.d.ts +3 -0
  2. package/dist/agent-git-status.d.ts.map +1 -0
  3. package/dist/agent-git-status.js +46 -0
  4. package/dist/agent-git-status.js.map +1 -0
  5. package/dist/agent-log-utils.d.ts +18 -0
  6. package/dist/agent-log-utils.d.ts.map +1 -0
  7. package/dist/agent-log-utils.js +123 -0
  8. package/dist/agent-log-utils.js.map +1 -0
  9. package/dist/agent-models.d.ts +4 -0
  10. package/dist/agent-models.d.ts.map +1 -0
  11. package/dist/agent-models.js +100 -0
  12. package/dist/agent-models.js.map +1 -0
  13. package/dist/agent-runner.d.ts +3 -5
  14. package/dist/agent-runner.d.ts.map +1 -1
  15. package/dist/agent-runner.js +8 -522
  16. package/dist/agent-runner.js.map +1 -1
  17. package/dist/agent-session-reader.d.ts +3 -0
  18. package/dist/agent-session-reader.d.ts.map +1 -0
  19. package/dist/agent-session-reader.js +113 -0
  20. package/dist/agent-session-reader.js.map +1 -0
  21. package/dist/build-pty-environment.d.ts +8 -0
  22. package/dist/build-pty-environment.d.ts.map +1 -0
  23. package/dist/build-pty-environment.js +44 -0
  24. package/dist/build-pty-environment.js.map +1 -0
  25. package/dist/caffeinate-automatic-detector.d.ts +57 -0
  26. package/dist/caffeinate-automatic-detector.d.ts.map +1 -0
  27. package/dist/caffeinate-automatic-detector.js +275 -0
  28. package/dist/caffeinate-automatic-detector.js.map +1 -0
  29. package/dist/caffeinate-battery-guard.d.ts +35 -0
  30. package/dist/caffeinate-battery-guard.d.ts.map +1 -0
  31. package/dist/caffeinate-battery-guard.js +190 -0
  32. package/dist/caffeinate-battery-guard.js.map +1 -0
  33. package/dist/caffeinate-manager.d.ts +5 -31
  34. package/dist/caffeinate-manager.d.ts.map +1 -1
  35. package/dist/caffeinate-manager.js +55 -400
  36. package/dist/caffeinate-manager.js.map +1 -1
  37. package/dist/cdp/cdp-client.d.ts +4 -70
  38. package/dist/cdp/cdp-client.d.ts.map +1 -1
  39. package/dist/cdp/cdp-client.js +58 -418
  40. package/dist/cdp/cdp-client.js.map +1 -1
  41. package/dist/cdp/cdp-connection.d.ts +72 -0
  42. package/dist/cdp/cdp-connection.d.ts.map +1 -0
  43. package/dist/cdp/cdp-connection.js +272 -0
  44. package/dist/cdp/cdp-connection.js.map +1 -0
  45. package/dist/cdp/constants.d.ts +6 -0
  46. package/dist/cdp/constants.d.ts.map +1 -0
  47. package/dist/cdp/constants.js +9 -0
  48. package/dist/cdp/constants.js.map +1 -0
  49. package/dist/cdp/target-registry.d.ts +41 -0
  50. package/dist/cdp/target-registry.d.ts.map +1 -0
  51. package/dist/cdp/target-registry.js +118 -0
  52. package/dist/cdp/target-registry.js.map +1 -0
  53. package/dist/constants.d.ts +2 -0
  54. package/dist/constants.d.ts.map +1 -1
  55. package/dist/constants.js +2 -0
  56. package/dist/constants.js.map +1 -1
  57. package/dist/git-branch-metadata.d.ts +11 -0
  58. package/dist/git-branch-metadata.d.ts.map +1 -0
  59. package/dist/git-branch-metadata.js +85 -0
  60. package/dist/git-branch-metadata.js.map +1 -0
  61. package/dist/git-diff-cache.d.ts +13 -0
  62. package/dist/git-diff-cache.d.ts.map +1 -0
  63. package/dist/git-diff-cache.js +32 -0
  64. package/dist/git-diff-cache.js.map +1 -0
  65. package/dist/git-diff-parser.d.ts +15 -0
  66. package/dist/git-diff-parser.d.ts.map +1 -0
  67. package/dist/git-diff-parser.js +196 -0
  68. package/dist/git-diff-parser.js.map +1 -0
  69. package/dist/git-diff-service.d.ts +10 -0
  70. package/dist/git-diff-service.d.ts.map +1 -0
  71. package/dist/git-diff-service.js +395 -0
  72. package/dist/git-diff-service.js.map +1 -0
  73. package/dist/git-diff.d.ts +8 -41
  74. package/dist/git-diff.d.ts.map +1 -1
  75. package/dist/git-diff.js +5 -944
  76. package/dist/git-diff.js.map +1 -1
  77. package/dist/github-pr.d.ts +32 -0
  78. package/dist/github-pr.d.ts.map +1 -0
  79. package/dist/github-pr.js +266 -0
  80. package/dist/github-pr.js.map +1 -0
  81. package/dist/pi-binary-resolver.d.ts +6 -0
  82. package/dist/pi-binary-resolver.d.ts.map +1 -0
  83. package/dist/pi-binary-resolver.js +70 -0
  84. package/dist/pi-binary-resolver.js.map +1 -0
  85. package/dist/pi-rpc-client.d.ts +14 -0
  86. package/dist/pi-rpc-client.d.ts.map +1 -0
  87. package/dist/pi-rpc-client.js +87 -0
  88. package/dist/pi-rpc-client.js.map +1 -0
  89. package/dist/session-client-hub.d.ts +57 -0
  90. package/dist/session-client-hub.d.ts.map +1 -0
  91. package/dist/session-client-hub.js +435 -0
  92. package/dist/session-client-hub.js.map +1 -0
  93. package/dist/session-command-executor.d.ts +14 -0
  94. package/dist/session-command-executor.d.ts.map +1 -0
  95. package/dist/session-command-executor.js +207 -0
  96. package/dist/session-command-executor.js.map +1 -0
  97. package/dist/session-git-event-bridge.d.ts +16 -0
  98. package/dist/session-git-event-bridge.d.ts.map +1 -0
  99. package/dist/session-git-event-bridge.js +56 -0
  100. package/dist/session-git-event-bridge.js.map +1 -0
  101. package/dist/session-lifecycle-policy.d.ts +15 -0
  102. package/dist/session-lifecycle-policy.d.ts.map +1 -0
  103. package/dist/session-lifecycle-policy.js +105 -0
  104. package/dist/session-lifecycle-policy.js.map +1 -0
  105. package/dist/session-manager.d.ts +11 -42
  106. package/dist/session-manager.d.ts.map +1 -1
  107. package/dist/session-manager.js +72 -987
  108. package/dist/session-manager.js.map +1 -1
  109. package/dist/session-output-coordinator.d.ts +21 -0
  110. package/dist/session-output-coordinator.d.ts.map +1 -0
  111. package/dist/session-output-coordinator.js +134 -0
  112. package/dist/session-output-coordinator.js.map +1 -0
  113. package/dist/session-output-transport.d.ts +22 -0
  114. package/dist/session-output-transport.d.ts.map +1 -0
  115. package/dist/session-output-transport.js +160 -0
  116. package/dist/session-output-transport.js.map +1 -0
  117. package/dist/session.d.ts +0 -4
  118. package/dist/session.d.ts.map +1 -1
  119. package/dist/session.js +11 -258
  120. package/dist/session.js.map +1 -1
  121. package/dist/shell-hook-builder.d.ts +16 -0
  122. package/dist/shell-hook-builder.d.ts.map +1 -0
  123. package/dist/shell-hook-builder.js +230 -0
  124. package/dist/shell-hook-builder.js.map +1 -0
  125. package/package.json +1 -1
@@ -1,49 +1,16 @@
1
- import { randomBytes } from "node:crypto";
2
1
  import path from "node:path";
3
- import zlib from "node:zlib";
4
- import { CaptureRenderer } from "./capture-renderer.js";
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
- const makeBrotliEncoder = (level) => {
10
- const enc = zlib.createBrotliCompress({
11
- params: { [zlib.constants.BROTLI_PARAM_QUALITY]: level },
12
- });
13
- let buf = Buffer.alloc(0);
14
- enc.on("data", (d) => {
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
- getGraceMs;
68
- pendingPromoteTimeoutMs;
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.getGraceMs = options.getGraceMs ?? (() => SESSION_GRACE_MS);
74
- this.pendingPromoteTimeoutMs =
75
- options.pendingPromoteTimeoutMs ?? SESSION_PENDING_PROMOTE_TIMEOUT_MS;
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
- if (this.sessions.size < MAX_CONCURRENT_SESSIONS)
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
- const byKey = new Map();
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
- const entry = this.wsToClient.get(ws);
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
- let count = 0;
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.size >= MAX_CONCURRENT_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
- const managed = this.sessionFor(id, owner);
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
- const entry = this.wsToClient.get(ws);
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
- const entry = this.wsToClient.get(ws);
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
- const entry = this.wsToClient.get(ws);
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
- const entry = this.wsToClient.get(ws);
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
- const entry = this.wsToClient.get(ws);
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.coordinatorsByCwd.clear();
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
- const capped = lines && lines > 0 ? Math.min(lines, CAPTURE_PANE_MAX_LINES) : undefined;
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
- const session = managed.session;
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
- const renderer = await this.ensureCaptureRenderer(managed);
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
- const session = managed.session;
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
- managed.gitWatcher.stop();
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
- session.on("git-dirty", () => {
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
- managed.gitWatcher.on("git-dirty", () => {
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
- if (managed.graceTimer !== null) {
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
- managed.gitWatcher.dispose();
1315
- for (const client of managed.clients) {
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
- for (const managed of this.sessions.values()) {
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.coordinatorsByCwd.get(path.resolve(cwd))?.broadcastPr(pr);
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.coordinatorsByCwd.has(path.resolve(cwd));
530
+ return this.gitEventBridge.hasCoordinatorFor(cwd);
1446
531
  }
1447
532
  }
1448
533
  //# sourceMappingURL=session-manager.js.map