@monotykamary/localterm-server 2.15.3 → 2.16.1

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/index.js CHANGED
@@ -14,23 +14,21 @@ import { CaffeinateController } from "./caffeinate-controller.js";
14
14
  import { CaffeinateManager } from "./caffeinate-manager.js";
15
15
  import { CaffeinatePreferencesStore } from "./caffeinate-preferences-store.js";
16
16
  import { CdpClient } from "./cdp/cdp-client.js";
17
- import { AUTOMATION_EVENT_DEBOUNCE_MS, AUTOMATION_RECONCILE_MIN_DOWNTIME_MS, AUTOMATION_RUN_QUERY_PARAM, AUTOMATION_WATCH_DEBOUNCE_MS, AUTOMATION_WATCH_POST_RUN_GRACE_MS, AUTOMATION_WEBHOOK_DEBOUNCE_MS, DEFAULT_HOST, DEFAULT_PORT, FRIENDLY_HOSTNAME, GIT_MAX_REF_LENGTH, HTTP_STATUS_ACCEPTED, HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_CONFLICT, HTTP_STATUS_CREATED, HTTP_STATUS_NOT_FOUND, MAX_AUTOMATIONS, MAX_CONCURRENT_SESSIONS, MAX_OUTPUT_BYTES, MS_PER_MINUTE, OUTPUT_BATCH_FLUSH_BYTES, OUTPUT_BATCH_WINDOW_MS, SERVER_STOP_GRACE_MS, SESSION_GRACE_MS, SESSION_ID_QUERY_PARAM, WS_BACKPRESSURE_THRESHOLD_BYTES, WS_CLOSE_BACKPRESSURE, WS_CLOSE_CAPACITY_REACHED, WS_CLOSE_POLICY_VIOLATION, WS_HEARTBEAT_GRACE_MS, WS_HEARTBEAT_INTERVAL_MS, WS_HEARTBEAT_TIMEOUT_MS, WS_OUTBOUND_DRAIN_POLL_MS, WS_OUTBOUND_PAUSE_HIGH_WATER_BYTES, WS_OUTBOUND_RESUME_LOW_WATER_BYTES, WS_READY_STATE_OPEN, } from "./constants.js";
17
+ import { AUTOMATION_EVENT_DEBOUNCE_MS, AUTOMATION_RECONCILE_MIN_DOWNTIME_MS, AUTOMATION_RUN_QUERY_PARAM, AUTOMATION_WATCH_DEBOUNCE_MS, AUTOMATION_WATCH_POST_RUN_GRACE_MS, AUTOMATION_WEBHOOK_DEBOUNCE_MS, DEFAULT_HOST, DEFAULT_PORT, FRIENDLY_HOSTNAME, GIT_MAX_REF_LENGTH, HTTP_STATUS_ACCEPTED, HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_CONFLICT, HTTP_STATUS_CREATED, HTTP_STATUS_NOT_FOUND, MAX_AUTOMATIONS, MS_PER_MINUTE, SERVER_STOP_GRACE_MS, SESSION_ID_QUERY_PARAM, WS_BACKPRESSURE_THRESHOLD_BYTES, WS_CLOSE_BACKPRESSURE, WS_CLOSE_CAPACITY_REACHED, WS_CLOSE_POLICY_VIOLATION, WS_HEARTBEAT_GRACE_MS, WS_HEARTBEAT_INTERVAL_MS, WS_HEARTBEAT_TIMEOUT_MS, WS_READY_STATE_OPEN, } from "./constants.js";
18
18
  import { getDefaultShell } from "./default-shell.js";
19
19
  import { shellPathForUserShell } from "./utils/shell-path.js";
20
20
  import { ServerErrorException, serverError } from "./errors.js";
21
21
  import { FolderWatchManager } from "./folder-watch-manager.js";
22
22
  import { SessionEventManager } from "./session-event-manager.js";
23
23
  import { WebhookTriggerManager } from "./webhook-trigger-manager.js";
24
- import { getGitBranchInfo, getGitBranchPr, getGitDiff, getGitDiffFilePatch, getGitDiffFiles, getGitDiffSummary, invalidateGitDiffCache, } from "./git-diff.js";
25
- import { GitDiffWatcher, GIT_DIFF_WATCHER_EVENT_NAMES, } from "./git-diff-watcher.js";
24
+ import { getGitBranchInfo, getGitBranchPr, getGitDiff, getGitDiffFilePatch, getGitDiffFiles, getGitDiffSummary, } from "./git-diff.js";
26
25
  import { HeartbeatStore } from "./heartbeat-store.js";
27
26
  import { parseCronExpression } from "./cron-expression.js";
28
27
  import { createGitWorktree, listGitWorktrees, removeGitWorktree } from "./git-worktrees.js";
29
28
  import { clientToServerMessageSchema, createAutomationInputSchema, createWorktreeInputSchema, launchInputSchema, resetAutomationInputSchema, updateAutomationInputSchema, updateWorktreeConfigInputSchema, worktreeIncludeFileInputSchema, } from "./schemas.js";
30
- import { Session } from "./session.js";
31
29
  import { createNetworkPolicyMiddleware, isAllowedSourceIp, isLoopbackHost } from "./security.js";
32
- import { SessionReattachPool, generateSessionId } from "./session-reattach-pool.js";
33
- import { SessionRegistry } from "./session-registry.js";
30
+ import { SessionManager } from "./session-manager.js";
31
+ import { getBufferedAmount } from "./utils/ws-socket.js";
34
32
  import { resolveStaticAsset } from "./static-resolver.js";
35
33
  import { resolveImageAsset } from "./utils/resolve-image-asset.js";
36
34
  import { sweepStaleWorktrees } from "./utils/worktree-sweep.js";
@@ -41,12 +39,6 @@ import { computeNextAutomationRunAt } from "./utils/compute-next-automation-run-
41
39
  import { isLocaltermTabUrl } from "./utils/is-localterm-tab-url.js";
42
40
  import { normalizeTriggerInput } from "./utils/normalize-trigger.js";
43
41
  import { enumerateMissedOccurrences } from "./utils/reconcile-downtime.js";
44
- const getRawBufferedAmount = (raw) => {
45
- if (!raw || typeof raw !== "object")
46
- return 0;
47
- const candidate = Reflect.get(raw, "bufferedAmount");
48
- return typeof candidate === "number" ? candidate : 0;
49
- };
50
42
  const callRawMethod = (raw, method) => {
51
43
  if (!raw || typeof raw !== "object")
52
44
  return false;
@@ -90,7 +82,7 @@ const extractRemoteAddress = (raw) => {
90
82
  const safeSend = (ws, payload) => {
91
83
  if (ws.readyState !== WS_READY_STATE_OPEN)
92
84
  return;
93
- if (getRawBufferedAmount(ws.raw) > WS_BACKPRESSURE_THRESHOLD_BYTES) {
85
+ if (getBufferedAmount(ws) > WS_BACKPRESSURE_THRESHOLD_BYTES) {
94
86
  ws.close(WS_CLOSE_BACKPRESSURE, "backpressure");
95
87
  return;
96
88
  }
@@ -101,111 +93,6 @@ const safeSend = (ws, payload) => {
101
93
  /* socket closed between readyState check and send */
102
94
  }
103
95
  };
104
- // Output frames travel as raw UTF-8 bytes, not JSON. JSON.stringify/parse on
105
- // terminal output is the dominant per-byte cost on the renderer main thread
106
- // (traced: ~36% of main-thread busy in steady-state cmatrix is JSON.parse of
107
- // {"type":"output","data":"..."}, scaling linearly with payload size because of
108
- // per-character escape scanning on both sides). PTY output is already bytes; the
109
- // server UTF-8-encodes the accumulated string batch once at flush and emits a
110
- // single binary frame. The client gets event.data as an ArrayBuffer and hands
111
- // it to OutputBatcher with no JSON.parse, no string roundtrip. Splits at
112
- // MAX_OUTPUT_BYTES as a safety cap on single-frame size (unreachable in
113
- // practice — OUTPUT_BATCH_FLUSH_BYTES=32KB flushes well below this).
114
- const sendOutputBytes = (ws, bytes) => {
115
- if (ws.readyState !== WS_READY_STATE_OPEN)
116
- return;
117
- if (getRawBufferedAmount(ws.raw) > WS_BACKPRESSURE_THRESHOLD_BYTES) {
118
- ws.close(WS_CLOSE_BACKPRESSURE, "backpressure");
119
- return;
120
- }
121
- try {
122
- ws.send(bytes);
123
- }
124
- catch {
125
- /* socket closed between readyState check and send */
126
- }
127
- };
128
- // Stateless UTF-8 encode + chunked send of the batch string. Shared by the
129
- // onOpen flush path (which additionally enforces per-session backpressure)
130
- // and the onClose/onError teardown paths (which don't — the socket is already
131
- // closing, so triggering a PTY pause would just stall the teardown).
132
- const sendOutputBatchBytes = (ws, batch) => {
133
- if (!batch)
134
- return;
135
- const bytes = Buffer.from(batch, "utf8");
136
- if (bytes.byteLength <= MAX_OUTPUT_BYTES) {
137
- sendOutputBytes(ws, bytes);
138
- }
139
- else {
140
- for (let offset = 0; offset < bytes.byteLength; offset += MAX_OUTPUT_BYTES) {
141
- sendOutputBytes(ws, bytes.subarray(offset, offset + MAX_OUTPUT_BYTES));
142
- }
143
- }
144
- };
145
- // Git metadata is per-repo, not per-tab. Two tabs in the same cwd share one
146
- // working tree, so a git-dirty signal from one tab — its shell's precmd OSC
147
- // hook, or its fs watcher on .git — must refresh every tab in that cwd, not
148
- // just the one whose shell produced the prompt. Without this, a git operation
149
- // run inside one of two side-by-side tabs updates only that tab; the sibling
150
- // stays stale until its own shell next renders a prompt (its precmd hook) or
151
- // its fs watcher happens to surface the change. The summary is pathscoped to
152
- // the cwd (`git diff` from a subdirectory lists only files under it), so the
153
- // coordinator is keyed by cwd, not by repo — tabs in different subdirectories
154
- // of the same repo get distinct summaries and never share.
155
- //
156
- // One coordinator per cwd also dedups the summary computation across concurrent
157
- // signals from sibling tabs: their independent fs watchers and prompt hooks all
158
- // funnel into a single in-flight pass (with one trailing pass after the burst
159
- // settles), and the result is broadcast to every subscribed socket.
160
- class GitDirtyCoordinator {
161
- cwd;
162
- inFlight = false;
163
- pending = false;
164
- subscribers = new Set();
165
- constructor(cwd) {
166
- this.cwd = cwd;
167
- }
168
- add(socket) {
169
- this.subscribers.add(socket);
170
- }
171
- remove(socket) {
172
- this.subscribers.delete(socket);
173
- }
174
- get isEmpty() {
175
- return this.subscribers.size === 0;
176
- }
177
- signal() {
178
- if (this.inFlight) {
179
- this.pending = true;
180
- return;
181
- }
182
- this.inFlight = true;
183
- void this.run();
184
- }
185
- run = async () => {
186
- try {
187
- // The working tree changed, so any cached full-diff pass for this cwd is
188
- // stale — drop it before re-reading the summary so the viewer's next
189
- // per-file fetch rebuilds against the new tree.
190
- invalidateGitDiffCache(this.cwd);
191
- const summary = await getGitDiffSummary(this.cwd);
192
- const payload = { type: "git-diff-summary", summary };
193
- for (const socket of this.subscribers) {
194
- safeSend(socket, payload);
195
- }
196
- }
197
- catch {
198
- /* transient git failure; the next signal retries */
199
- }
200
- finally {
201
- this.inFlight = false;
202
- if (this.pending) {
203
- this.pending = false;
204
- this.signal();
205
- }
206
- }
207
- };
208
- }
209
96
  export const createServer = async (options = {}) => {
210
97
  const port = options.port ?? DEFAULT_PORT;
211
98
  const host = options.host ?? DEFAULT_HOST;
@@ -214,12 +101,37 @@ export const createServer = async (options = {}) => {
214
101
  if (!isLoopbackBind) {
215
102
  console.warn(`⚠ non-loopback bind (${host}): any client on the private network can open an unauthenticated shell`);
216
103
  }
217
- const registry = new SessionRegistry();
218
- // PTY reattach pool: a WS close (portless teardown on wake, transient drop)
219
- // parks the live Session here instead of killing it. The next WS open
220
- // carrying the matching `?sid=` reattaches; a grace timer disposes
221
- // abandoned PTYs whose client never comes back.
222
- const reattachPool = new SessionReattachPool({ graceMs: SESSION_GRACE_MS });
104
+ // The session manager owns every live PTY for the daemon's lifetime. A PTY
105
+ // persists across client detach (closing a tab detaches instead of killing
106
+ // it) so the session picker can re-attach to it; it dies on shell exit, an
107
+ // explicit kill from the picker, or the dormant-idle sweep. Multiple clients
108
+ // may attach to one PTY and fan out output/resize to all of them. The hooks
109
+ // close over managers defined further below; they only fire at runtime
110
+ // (attach/detach/output/exit), so referencing the later consts here is safe.
111
+ const registry = new SessionManager({
112
+ sendControl: safeSend,
113
+ hooks: {
114
+ onOutputActivity: () => caffeinateManager.noteOutputActivity(),
115
+ onSessionActivity: () => caffeinateManager.pokeAuto(),
116
+ onSessionEvent: (event, cwd) => sessionEventManager.onSessionEvent(event, cwd),
117
+ onAutomationExit: (automationId, runId, exitCode) => {
118
+ automationStore.updateRun(automationId, runId, {
119
+ status: exitCode === 0 ? "completed" : "failed",
120
+ exitCode,
121
+ finishedAt: Date.now(),
122
+ });
123
+ broadcastAutomations();
124
+ closeRunTabIfRequested(automationId, runId);
125
+ folderWatchManager.notifyRunFinished(automationId);
126
+ sessionEventManager.notifyRunFinished(automationId);
127
+ },
128
+ onClientExit: (ws, exitCode) => {
129
+ const targetId = wsToTargetId.get(ws);
130
+ if (targetId && (exitCode === null || exitCode === 0))
131
+ void cdpClient?.closeTab(targetId);
132
+ },
133
+ },
134
+ });
223
135
  const app = new Hono();
224
136
  app.use("*", createNetworkPolicyMiddleware(host));
225
137
  const { injectWebSocket, upgradeWebSocket, wss } = createNodeWebSocket({ app });
@@ -275,25 +187,11 @@ export const createServer = async (options = {}) => {
275
187
  hasRecentOutput: (pids, withinMs) => registry.hasRecentOutput(pids, withinMs),
276
188
  });
277
189
  const clientSockets = new Set();
278
- // One GitDirtyCoordinator per cwd, shared by every tab whose session is in
279
- // that cwd. A tab subscribes on open and resubscribes on every `cd`; a
280
- // git-dirty signal from any tab in the cwd broadcasts the recomputed summary
281
- // to all of them. Emptied coordinators are dropped so a cwd no tab is in
282
- // holds no watcher state.
283
- const gitDirtyCoordinatorsByCwd = new Map();
284
- const coordinatorForCwd = (cwd) => {
285
- const key = path.resolve(cwd);
286
- let coordinator = gitDirtyCoordinatorsByCwd.get(key);
287
- if (!coordinator) {
288
- coordinator = new GitDirtyCoordinator(key);
289
- gitDirtyCoordinatorsByCwd.set(key, coordinator);
290
- }
291
- return coordinator;
292
- };
293
- const releaseGitDirtyCoordinator = (coordinator) => {
294
- if (coordinator.isEmpty)
295
- gitDirtyCoordinatorsByCwd.delete(coordinator.cwd);
296
- };
190
+ // CDP target paired with each WS via the {type:"identify"} handshake, so the
191
+ // manager's onClientExit hook can drive closeTab on a clean shell exit for
192
+ // that specific socket. Per-WS (a CDP target belongs to one page); cleared
193
+ // on detach.
194
+ const wsToTargetId = new Map();
297
195
  const cdpBackgroundTabsDisabled = process.env.LOCALTERM_DISABLE_CDP_TABS === "1";
298
196
  // One persistent CDP socket for the daemon's lifetime — opened once at start
299
197
  // (below), so the user clears the browser's remote-debugging prompt a single
@@ -497,6 +395,17 @@ export const createServer = async (options = {}) => {
497
395
  };
498
396
  const api = new Hono();
499
397
  api.get("/health", (context) => context.json({ ok: true, sessions: registry.size() }));
398
+ // The session picker: every live PTY (attached or dormant), so a tab can
399
+ // switch to one by id or kill one it no longer wants. `clients` is the count
400
+ // of attached sockets — 0 marks a dormant shell left behind by a closed tab,
401
+ // which is exactly the row the picker exists to surface.
402
+ api.get("/sessions", (context) => context.json({ sessions: registry.list() }));
403
+ api.delete("/sessions/:id", (context) => {
404
+ const killed = registry.kill(context.req.param("id"));
405
+ if (!killed)
406
+ return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
407
+ return context.json({ ok: true });
408
+ });
500
409
  // Same validation as the WS `?cwd=` param: must exist and be a directory.
501
410
  // No path containment check — this daemon already hands out unrestricted
502
411
  // shells, so reading a diff is not an escalation.
@@ -894,41 +803,18 @@ export const createServer = async (options = {}) => {
894
803
  api.notFound((context) => context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND));
895
804
  app.route("/api", api);
896
805
  app.get("/ws", upgradeWebSocket((context) => {
897
- let session = null;
898
806
  let activeWs = null;
899
807
  let claimedRunId = null;
900
- // Server-side id for the live PTY attached to this WS. Sent to the
901
- // client in the {type:"session"} message so a reconnect carries it back
902
- // as `?sid=` and the daemon reattaches the parked Session instead of
903
- // spawning a fresh shell. Cleared on genuine shell exit (no reattach).
808
+ // Server-side id of the PTY this WS is attached to. Sent to the client in
809
+ // the {type:"session"} frame so a reconnect or switch carries it back as
810
+ // `?sid=` and the manager attaches to the live PTY instead of spawning.
904
811
  let sessionId = null;
905
- // Persisted across park/reattach so onClose/onError can re-park the
906
- // automation-run context (the run-tracker claim is single-use, so we
907
- // can't re-derive automationId/runId from `?run=` on reconnect).
908
- let automationId = null;
909
- // The CDP targetId this WS socket was paired with via the
910
- // `{type:"identify"}` handshake (page's ambient token →
911
- // CdpClient.findTargetIdForToken). Set on identify; drives closeTab on
912
- // clean shell exit. Stays null when no CDP is reachable, the token raced
913
- // (page opened before the CdpClient observed it), or the page never
914
- // re-identified.
915
- let claimedTargetId = null;
916
- // The per-cwd git-dirty coordinator this socket is currently subscribed
917
- // to. Lives in this outer scope (not onOpen's) so the shared
918
- // `releaseSessionFromSocket` finalization — called by both onClose and
919
- // onError — can unsubscribe it. Moves whenever the session's cwd changes.
920
- let gitDirtyCoordinator = null;
921
- let drainPollTimer = null;
812
+ // The managed session this socket is attached to (null after detach). The
813
+ // manager owns the PTY's listeners, fan-out, and lifecycle; this reference
814
+ // is only for the heartbeat's pid label.
815
+ let managed = null;
922
816
  let heartbeatTimer = null;
923
817
  let stopHeartbeat = null;
924
- let outputBatch = "";
925
- let outputBatchTimer = null;
926
- const stopDrainPoll = () => {
927
- if (drainPollTimer === null)
928
- return;
929
- clearInterval(drainPollTimer);
930
- drainPollTimer = null;
931
- };
932
818
  const stopHeartbeatChecks = () => {
933
819
  if (heartbeatTimer !== null) {
934
820
  clearInterval(heartbeatTimer);
@@ -949,42 +835,21 @@ export const createServer = async (options = {}) => {
949
835
  }
950
836
  };
951
837
  // Single-shot finalization shared by onClose/onError (ws fires error then
952
- // close on a transport failure; without this guard both would try to
953
- // dispose/park the same Session). Parks a still-live PTY behind `sid` so
954
- // a reconnecting client with `?sid=` can reattach; disposes on genuine
955
- // shell exit, when no sid was minted, or when the session is already
956
- // gone.
838
+ // close on a transport failure; without this guard both would detach
839
+ // twice). Detaches this socket from its PTY — the PTY itself stays alive
840
+ // (dormant if this was the last client) so the session picker can
841
+ // re-attach to it. The manager disposes the PTY on shell exit or kill.
957
842
  let sessionFinalized = false;
958
843
  const releaseSessionFromSocket = () => {
959
- if (sessionFinalized)
960
- return;
961
- if (!session)
844
+ if (sessionFinalized || !activeWs)
962
845
  return;
963
846
  sessionFinalized = true;
964
- const live = session;
965
- registry.unregister(live);
966
- caffeinateManager.pokeAuto();
967
- if (!live.isExited && sessionId) {
968
- reattachPool.park(live, {
969
- sid: sessionId,
970
- claimedRunId,
971
- claimedTargetId,
972
- automationId,
973
- });
974
- }
975
- else {
976
- live.dispose();
977
- }
978
- // Unsubscribe from the per-cwd git-dirty coordinator so a closed
979
- // tab stops receiving (and stops keeping alive) broadcasts for its
980
- // former cwd. `activeWs` is the socket this coordinator was added
981
- // under; it's nulled below, so capture it first.
982
- if (gitDirtyCoordinator && activeWs) {
983
- gitDirtyCoordinator.remove(activeWs);
984
- releaseGitDirtyCoordinator(gitDirtyCoordinator);
985
- gitDirtyCoordinator = null;
986
- }
987
- session = null;
847
+ const ws = activeWs;
848
+ registry.detach(ws);
849
+ wsToTargetId.delete(ws);
850
+ clientSockets.delete(ws);
851
+ releaseRunTabHandle();
852
+ managed = null;
988
853
  activeWs = null;
989
854
  };
990
855
  const rawCwd = context.req.query("cwd");
@@ -1016,37 +881,23 @@ export const createServer = async (options = {}) => {
1016
881
  return;
1017
882
  }
1018
883
  }
1019
- if (registry.size() >= MAX_CONCURRENT_SESSIONS) {
1020
- ws.close(WS_CLOSE_CAPACITY_REACHED, "session capacity reached");
1021
- return;
1022
- }
1023
- clientSockets.add(ws);
1024
- // Claims are single-use: a reload of a ?run= tab gets a plain shell
1025
- // in the same cwd instead of re-running the scheduled command.
1026
- const claimedRun = requestedRunId ? automationRunTracker.claim(requestedRunId) : null;
1027
- if (claimedRun)
1028
- claimedRunId = claimedRun.runId;
1029
- // Reattach: if the WS carries a `?sid=` for a PTY the pool still has
1030
- // parked (transient drop — portless teardown on wake, brief network
1031
- // blip), rebind the live Session to this socket instead of spawning
1032
- // a new shell. A `claim()` miss (grace expired, or shell exited while
1033
- // parked) falls through to the spawn path.
1034
- const parked = requestedSid ? reattachPool.claim(requestedSid) : null;
1035
- const isReattach = parked !== null;
1036
- let liveSession;
1037
- if (parked) {
1038
- liveSession = parked.session;
1039
- sessionId = parked.sid;
1040
- claimedRunId = parked.claimedRunId;
1041
- claimedTargetId = parked.claimedTargetId;
1042
- automationId = parked.automationId;
1043
- // Re-register so the live PTY counts toward MAX_CONCURRENT_SESSIONS
1044
- // and caffeinate's ps-tree walk again. park() unregistered it on
1045
- // the prior WS close; if we skipped this, a transient drop would
1046
- // leave the PTY off the books until the next reconnect.
1047
- registry.register(liveSession);
884
+ // Reattach if `?sid=` names a PTY the manager still has live (a
885
+ // transient drop, or a switch from the session picker). A miss
886
+ // (shell exited while dormant, killed, or reaped by the idle
887
+ // sweep) falls through to a fresh spawn.
888
+ const attached = requestedSid ? registry.attach(ws, requestedSid) : null;
889
+ if (attached) {
890
+ managed = attached;
891
+ sessionId = attached.id;
1048
892
  }
1049
893
  else {
894
+ if (registry.atCapacity()) {
895
+ ws.close(WS_CLOSE_CAPACITY_REACHED, "session capacity reached");
896
+ return;
897
+ }
898
+ // Claims are single-use: a reload of a ?run= tab gets a plain
899
+ // shell in the same cwd instead of re-running the scheduled command.
900
+ const claimedRun = requestedRunId ? automationRunTracker.claim(requestedRunId) : null;
1050
901
  let sessionCwd = requestedCwd;
1051
902
  if (claimedRun) {
1052
903
  try {
@@ -1057,39 +908,29 @@ export const createServer = async (options = {}) => {
1057
908
  /* automation cwd vanished since creation; fall back to default */
1058
909
  }
1059
910
  }
1060
- const freshSession = new Session({
1061
- cwd: sessionCwd,
1062
- initialCommand: claimedRun?.command ?? requestedInitialCommand,
1063
- });
1064
- liveSession = freshSession;
1065
- sessionId = generateSessionId();
1066
- if (claimedRun)
1067
- automationId = claimedRun.automationId;
1068
- registry.register(freshSession);
1069
- }
1070
- session = liveSession;
1071
- const automationRunId = claimedRunId;
1072
- const isAutomationSession = automationId !== null;
1073
- if (isAutomationSession && !isReattach) {
1074
- automationStore.updateRun(automationId, automationRunId, {
1075
- status: "running",
1076
- startedAt: Date.now(),
1077
- });
1078
- broadcastAutomations();
1079
- }
1080
- if (isAutomationSession) {
1081
- liveSession.on("automation-exit", (exitCode) => {
1082
- automationStore.updateRun(automationId, automationRunId, {
1083
- status: exitCode === 0 ? "completed" : "failed",
1084
- exitCode,
1085
- finishedAt: Date.now(),
911
+ const automation = claimedRun
912
+ ? { automationId: claimedRun.automationId, runId: claimedRun.runId }
913
+ : undefined;
914
+ const spawned = registry.spawnAndAttach(ws, { cwd: sessionCwd, initialCommand: claimedRun?.command ?? requestedInitialCommand }, automation);
915
+ if (!spawned) {
916
+ ws.close(WS_CLOSE_CAPACITY_REACHED, "session capacity reached");
917
+ return;
918
+ }
919
+ managed = spawned;
920
+ sessionId = spawned.id;
921
+ if (claimedRun) {
922
+ claimedRunId = claimedRun.runId;
923
+ automationStore.updateRun(claimedRun.automationId, claimedRun.runId, {
924
+ status: "running",
925
+ startedAt: Date.now(),
1086
926
  });
1087
927
  broadcastAutomations();
1088
- closeRunTabIfRequested(automationId, automationRunId);
1089
- folderWatchManager.notifyRunFinished(automationId);
1090
- sessionEventManager.notifyRunFinished(automationId);
1091
- });
928
+ }
1092
929
  }
930
+ if (!managed)
931
+ return;
932
+ clientSockets.add(ws);
933
+ const liveSession = managed.session;
1093
934
  // Heartbeat. Without this, half-open sockets (laptop sleep, network
1094
935
  // dropout) never surface as a `close` event and the daemon keeps
1095
936
  // streaming PTY output into the void. We only enable it if the raw
@@ -1102,9 +943,7 @@ export const createServer = async (options = {}) => {
1102
943
  // sleep but the loopback socket itself never dropped), we send one
1103
944
  // fresh ping and wait through WS_HEARTBEAT_GRACE_MS for a pong before
1104
945
  // terminating. A live socket pongs inside the grace window; a truly
1105
- // half-open one stays silent and terminates on the next tick. This
1106
- // avoids killing sessions that survived a brief laptop sleep, while
1107
- // still tearing down genuinely dead sockets within ~one extra tick.
946
+ // half-open one stays silent and terminates on the next tick.
1108
947
  let lastPongAt = Date.now();
1109
948
  let pendingPingAt = 0;
1110
949
  stopHeartbeat = onRawEvent(ws.raw, "pong", () => {
@@ -1135,165 +974,6 @@ export const createServer = async (options = {}) => {
1135
974
  }, WS_HEARTBEAT_INTERVAL_MS);
1136
975
  heartbeatTimer.unref?.();
1137
976
  }
1138
- // Outbound flow control. When the WS buffer climbs past the high
1139
- // water mark we pause the PTY (OS pipe back-pressure stops the
1140
- // child process producing more output) and start polling for the
1141
- // buffer to drain back below the low water mark. This way bursty
1142
- // output (`cat`, build logs, npm install) doesn't kill the
1143
- // connection — only a genuinely wedged receiver eventually trips
1144
- // the WS_BACKPRESSURE_THRESHOLD_BYTES emergency in safeSend.
1145
- const ensureDrainPoll = () => {
1146
- if (drainPollTimer !== null)
1147
- return;
1148
- drainPollTimer = setInterval(() => {
1149
- if (!liveSession.isPaused) {
1150
- stopDrainPoll();
1151
- return;
1152
- }
1153
- if (getRawBufferedAmount(ws.raw) <= WS_OUTBOUND_RESUME_LOW_WATER_BYTES) {
1154
- liveSession.resume();
1155
- stopDrainPoll();
1156
- }
1157
- }, WS_OUTBOUND_DRAIN_POLL_MS);
1158
- drainPollTimer.unref?.();
1159
- };
1160
- // Drain-and-pause for the timer-driven and threshold flushes; the per-
1161
- // session backpressure pause check lives here (and only here — the
1162
- // onClose/onError teardown paths skip it via sendOutputBatchBytes
1163
- // directly).
1164
- const drainOutputBatch = (target) => {
1165
- sendOutputBatchBytes(target, outputBatch);
1166
- outputBatch = "";
1167
- if (!liveSession.isPaused &&
1168
- getRawBufferedAmount(target.raw) >= WS_OUTBOUND_PAUSE_HIGH_WATER_BYTES) {
1169
- liveSession.pause();
1170
- ensureDrainPoll();
1171
- }
1172
- };
1173
- const flushOutputBatch = () => {
1174
- outputBatchTimer = null;
1175
- drainOutputBatch(ws);
1176
- };
1177
- // Wire listeners so any emit from Session (current or future)
1178
- // reaches the client. Today node-pty's data/exit are async, but
1179
- // this guards against drift.
1180
- const onOutput = (data) => {
1181
- outputBatch += data;
1182
- registry.noteOutput(liveSession.pid);
1183
- caffeinateManager.noteOutputActivity();
1184
- if (outputBatch.length >= OUTPUT_BATCH_FLUSH_BYTES) {
1185
- if (outputBatchTimer !== null) {
1186
- clearTimeout(outputBatchTimer);
1187
- outputBatchTimer = null;
1188
- }
1189
- flushOutputBatch();
1190
- }
1191
- else if (outputBatchTimer === null) {
1192
- outputBatchTimer = setTimeout(flushOutputBatch, OUTPUT_BATCH_WINDOW_MS);
1193
- }
1194
- };
1195
- const onTitle = (title) => safeSend(ws, { type: "title", title });
1196
- const onCwd = (cwd) => safeSend(ws, { type: "cwd", cwd });
1197
- const onForeground = (process) => {
1198
- safeSend(ws, { type: "foreground", process });
1199
- // A foreground transition is the cheap signal that a recognized
1200
- // program may have started or stopped — nudge automatic detection.
1201
- caffeinateManager.pokeAuto();
1202
- };
1203
- const onNotification = (body) => safeSend(ws, { type: "notification", body });
1204
- const onExit = (code) => {
1205
- // Reliable closeTab on a clean shell exit for CDP-controlled tabs.
1206
- // closeTab drives the browser's own close path via CDP instead of
1207
- // relying on the client's window.close() — which often doesn't
1208
- // apply (Dia/Arc, or a tab the user opened by URL rather than via
1209
- // window.open) and strands the tab. Fire-and-forget onto the same
1210
- // closeQueue that serializes automation-run closes, so concurrent
1211
- // Ctrl+Ds across tabs never interleave and orphan targets.
1212
- // Skipped on non-zero exit codes so the dead-session mask surfaces
1213
- // the failure instead of closing the tab silently.
1214
- if (claimedTargetId && (code === null || code === 0)) {
1215
- void cdpClient?.closeTab(claimedTargetId);
1216
- }
1217
- if (outputBatchTimer !== null) {
1218
- clearTimeout(outputBatchTimer);
1219
- outputBatchTimer = null;
1220
- }
1221
- flushOutputBatch();
1222
- stopDrainPoll();
1223
- stopHeartbeatChecks();
1224
- gitDiffWatcher.dispose();
1225
- safeSend(ws, { type: "exit", code });
1226
- ws.close();
1227
- };
1228
- const gitDiffWatcher = new GitDiffWatcher();
1229
- // Subscribe this tab to the per-cwd git-dirty coordinator so a git
1230
- // change observed by any tab in the same cwd (its prompt hook or fs
1231
- // watcher) refreshes this tab too. The coordinator dedups the
1232
- // summary computation and broadcasts the result to every subscriber.
1233
- gitDirtyCoordinator = coordinatorForCwd(liveSession.cwd);
1234
- gitDirtyCoordinator.add(ws);
1235
- const signalGitDirty = () => {
1236
- const cwd = liveSession.lastEmittedCwd;
1237
- if (!cwd)
1238
- return;
1239
- coordinatorForCwd(cwd).signal();
1240
- };
1241
- gitDiffWatcher.on("git-dirty", () => {
1242
- signalGitDirty();
1243
- });
1244
- const gitAutomationEvents = GIT_DIFF_WATCHER_EVENT_NAMES.filter((eventName) => eventName !== "git-dirty");
1245
- for (const eventName of gitAutomationEvents) {
1246
- gitDiffWatcher.on(eventName, () => {
1247
- if (!isAutomationSession) {
1248
- sessionEventManager.onSessionEvent(eventName, liveSession.lastEmittedCwd);
1249
- }
1250
- });
1251
- }
1252
- gitDiffWatcher.start(liveSession.cwd);
1253
- // Automation-run sessions should not feed events into the session
1254
- // event manager — only user-driven sessions count.
1255
- liveSession.on("git-dirty", () => {
1256
- signalGitDirty();
1257
- if (!isAutomationSession) {
1258
- sessionEventManager.onSessionEvent("git-dirty", liveSession.lastEmittedCwd);
1259
- }
1260
- });
1261
- liveSession.on("cwd", (changedCwd) => {
1262
- gitDiffWatcher.stop();
1263
- gitDiffWatcher.start(changedCwd);
1264
- const nextCoordinator = coordinatorForCwd(changedCwd);
1265
- const current = gitDirtyCoordinator;
1266
- if (current && nextCoordinator !== current) {
1267
- current.remove(ws);
1268
- releaseGitDirtyCoordinator(current);
1269
- gitDirtyCoordinator = nextCoordinator;
1270
- nextCoordinator.add(ws);
1271
- }
1272
- if (!isAutomationSession) {
1273
- sessionEventManager.onSessionEvent("cwd", changedCwd);
1274
- }
1275
- });
1276
- liveSession.on("output", onOutput);
1277
- liveSession.on("title", onTitle);
1278
- liveSession.on("cwd", onCwd);
1279
- liveSession.on("foreground", (foregroundProcess) => {
1280
- onForeground(foregroundProcess);
1281
- if (!isAutomationSession) {
1282
- sessionEventManager.onSessionEvent("foreground", liveSession.lastEmittedCwd);
1283
- }
1284
- });
1285
- liveSession.on("notification", (body) => {
1286
- onNotification(body);
1287
- if (!isAutomationSession) {
1288
- sessionEventManager.onSessionEvent("notification", liveSession.lastEmittedCwd);
1289
- }
1290
- });
1291
- liveSession.on("exit", (code) => {
1292
- onExit(code);
1293
- if (!isAutomationSession && liveSession.lastEmittedCwd) {
1294
- sessionEventManager.onSessionEvent("exit", liveSession.lastEmittedCwd);
1295
- }
1296
- });
1297
977
  safeSend(ws, {
1298
978
  type: "session",
1299
979
  shell: liveSession.shell,
@@ -1308,8 +988,9 @@ export const createServer = async (options = {}) => {
1308
988
  safeSend(ws, caffeinateStatePayload());
1309
989
  },
1310
990
  onMessage(event) {
1311
- if (!session)
991
+ if (!activeWs)
1312
992
  return;
993
+ const ws = activeWs;
1313
994
  let rawPayload;
1314
995
  try {
1315
996
  const raw = typeof event.data === "string" ? event.data : event.data.toString();
@@ -1322,7 +1003,16 @@ export const createServer = async (options = {}) => {
1322
1003
  if (!parsed.success)
1323
1004
  return;
1324
1005
  if (parsed.data.type === "input") {
1325
- session.write(parsed.data.data);
1006
+ registry.writeInput(ws, parsed.data.data);
1007
+ }
1008
+ else if (parsed.data.type === "resize") {
1009
+ registry.resize(ws, parsed.data.cols, parsed.data.rows, parsed.data.pixelWidth, parsed.data.pixelHeight);
1010
+ }
1011
+ else if (parsed.data.type === "ready") {
1012
+ // Attach handshake: the client has the {type:"session"} frame and
1013
+ // says whether it wants the scrollback replay (a switch to a PTY
1014
+ // it didn't already have on screen) before live fan-out begins.
1015
+ registry.promote(ws, parsed.data.replay);
1326
1016
  }
1327
1017
  else if (parsed.data.type === "caffeinate-mode") {
1328
1018
  caffeinateManager.setMode(parsed.data.mode);
@@ -1339,8 +1029,8 @@ export const createServer = async (options = {}) => {
1339
1029
  else if (parsed.data.type === "identify") {
1340
1030
  // Ambient tab provenance: the page echoes the CDP-injected token so
1341
1031
  // we pair this socket with its targetId for closeTab on shell exit.
1342
- // `token:null` means injection hasn't landed yet (page opened its
1343
- // WS before the CdpClient observed it) — wait for the page to
1032
+ // `token:null` means injection hasn't landed yet (page opened its WS
1033
+ // before the CdpClient observed it) — wait for the page to
1344
1034
  // re-identify on the 'localterm-token' event rather than pairing
1345
1035
  // eagerly against a null token. We always ack the client either
1346
1036
  // way so its markShellDead path knows whether to fall back to
@@ -1349,58 +1039,31 @@ export const createServer = async (options = {}) => {
1349
1039
  if (token !== null) {
1350
1040
  const targetId = cdpClient?.findTargetIdForToken(token);
1351
1041
  if (targetId)
1352
- claimedTargetId = targetId;
1042
+ wsToTargetId.set(ws, targetId);
1353
1043
  }
1354
- if (activeWs) {
1355
- safeSend(activeWs, {
1356
- type: "cdp-controlled",
1357
- controlled: claimedTargetId !== null,
1358
- });
1359
- }
1360
- }
1361
- else {
1362
- session.resize(parsed.data.cols, parsed.data.rows, parsed.data.pixelWidth, parsed.data.pixelHeight);
1044
+ safeSend(ws, {
1045
+ type: "cdp-controlled",
1046
+ controlled: wsToTargetId.has(ws),
1047
+ });
1363
1048
  }
1364
1049
  },
1365
1050
  onClose(event) {
1366
- if (outputBatchTimer !== null) {
1367
- clearTimeout(outputBatchTimer);
1368
- outputBatchTimer = null;
1369
- }
1370
- if (outputBatch && activeWs) {
1371
- sendOutputBatchBytes(activeWs, outputBatch);
1372
- outputBatch = "";
1373
- }
1374
- stopDrainPoll();
1375
1051
  stopHeartbeatChecks();
1376
1052
  // Most "the terminal randomly died" reports are actually the WS
1377
1053
  // closing for a reason we never surfaced; logging code+reason+
1378
1054
  // wasClean here makes the next incident a 1-line lookup in
1379
1055
  // ~/.localterm/server.log.
1380
- const pidLabel = session ? ` pid ${session.pid}` : "";
1056
+ const pidLabel = managed ? ` pid ${managed.session.pid}` : "";
1381
1057
  console.info(`ws closed${pidLabel}: code=${event.code} reason=${JSON.stringify(event.reason)} wasClean=${event.wasClean}`);
1382
- if (activeWs)
1383
- clientSockets.delete(activeWs);
1384
1058
  releaseRunTabHandle();
1385
1059
  releaseSessionFromSocket();
1386
1060
  },
1387
1061
  onError(event) {
1388
- if (outputBatchTimer !== null) {
1389
- clearTimeout(outputBatchTimer);
1390
- outputBatchTimer = null;
1391
- }
1392
- if (outputBatch && activeWs) {
1393
- sendOutputBatchBytes(activeWs, outputBatch);
1394
- outputBatch = "";
1395
- }
1396
- stopDrainPoll();
1397
1062
  stopHeartbeatChecks();
1398
1063
  const errorValue = event && typeof event === "object" ? (Reflect.get(event, "error") ?? event) : event;
1399
1064
  const message = errorValue instanceof Error ? errorValue.message : String(errorValue);
1400
- const pidLabel = session ? ` pid ${session.pid}` : "";
1065
+ const pidLabel = managed ? ` pid ${managed.session.pid}` : "";
1401
1066
  console.error(`ws error${pidLabel}: ${message}`);
1402
- if (activeWs)
1403
- clientSockets.delete(activeWs);
1404
1067
  releaseRunTabHandle();
1405
1068
  releaseSessionFromSocket();
1406
1069
  },
@@ -1505,7 +1168,6 @@ export const createServer = async (options = {}) => {
1505
1168
  caffeinateManager.dispose();
1506
1169
  cdpClient?.close();
1507
1170
  registry.disposeAll();
1508
- reattachPool.disposeAll();
1509
1171
  // Forcibly tear down every WS first. node-pty + ws upgraded sockets
1510
1172
  // aren't tracked in http.Server's keep-alive set, so target.close() would
1511
1173
  // otherwise wait forever for them and the CLI's force-exit fallback would