agent-relay-orchestrator 0.27.1 → 0.27.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api.ts +64 -20
- package/src/control.ts +2 -1
- package/src/index.ts +5 -0
- package/src/spawn.ts +2 -2
- package/src/terminal-stream.ts +397 -87
- package/src/workspace-probe.ts +39 -4
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -79,6 +79,10 @@ interface TerminalSocketData {
|
|
|
79
79
|
ready?: boolean;
|
|
80
80
|
queue?: Uint8Array[];
|
|
81
81
|
syncTimer?: ReturnType<typeof setTimeout>;
|
|
82
|
+
// Bytes dropped (not sent) while this viewer was paused. On resume, 0 means the
|
|
83
|
+
// screen is unchanged → resume the live stream without a reset+backfill (which
|
|
84
|
+
// otherwise wipes scrollback/scroll position on every focus flick — #272).
|
|
85
|
+
droppedWhilePaused?: number;
|
|
82
86
|
}
|
|
83
87
|
|
|
84
88
|
type TerminalSocket = ServerWebSocket<TerminalSocketData>;
|
|
@@ -682,7 +686,11 @@ function startTerminalSocket(ws: TerminalSocket): void {
|
|
|
682
686
|
ws.data.queue = [];
|
|
683
687
|
const subscriber: TerminalStreamSubscriber = {
|
|
684
688
|
onData: (bytes) => {
|
|
685
|
-
if (ws.data.paused)
|
|
689
|
+
if (ws.data.paused) {
|
|
690
|
+
// Count what we drop so resume can tell whether a re-backfill is needed.
|
|
691
|
+
ws.data.droppedWhilePaused = (ws.data.droppedWhilePaused ?? 0) + bytes.length;
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
686
694
|
// Queue live bytes until the client has reported its size and the backfill is
|
|
687
695
|
// flushed, so the viewer never sees live output before (or mis-sized against) history.
|
|
688
696
|
if (!ws.data.ready) {
|
|
@@ -694,9 +702,16 @@ function startTerminalSocket(ws: TerminalSocket): void {
|
|
|
694
702
|
} catch {}
|
|
695
703
|
},
|
|
696
704
|
onClose: (reason) => {
|
|
705
|
+
// Tell the client why, THEN actually close the socket. Sending only the frame
|
|
706
|
+
// (the old behaviour) left a live-looking socket the client never reconnected
|
|
707
|
+
// on — a backpressure drop made the terminal silently dead to input and output
|
|
708
|
+
// (#271). The client reconnects + re-backfills on a transient reason.
|
|
697
709
|
try {
|
|
698
710
|
ws.send(JSON.stringify({ type: "closed", reason: reason ?? null }));
|
|
699
711
|
} catch {}
|
|
712
|
+
try {
|
|
713
|
+
ws.close();
|
|
714
|
+
} catch {}
|
|
700
715
|
},
|
|
701
716
|
bufferedAmount: () => {
|
|
702
717
|
try {
|
|
@@ -732,30 +747,47 @@ async function syncAndBackfill(ws: TerminalSocket, cols?: number, rows?: number)
|
|
|
732
747
|
}
|
|
733
748
|
if (ws.data.synced) return;
|
|
734
749
|
ws.data.synced = true;
|
|
750
|
+
// Initial connect: live bytes are still queued (ready=false), so the reset+content can't
|
|
751
|
+
// split a live sequence — emit immediately (no ground gate, no first-paint latency).
|
|
735
752
|
await sendBackfill(ws, cols, rows);
|
|
736
|
-
|
|
737
|
-
|
|
753
|
+
// But only START forwarding live output at a sequence boundary, so the first forwarded
|
|
754
|
+
// flush doesn't begin mid-escape-sequence (an orphan tail on the client — #276).
|
|
755
|
+
const stream = ws.data.stream;
|
|
756
|
+
const flip = () => {
|
|
757
|
+
ws.data.queue = [];
|
|
758
|
+
ws.data.ready = true;
|
|
759
|
+
};
|
|
760
|
+
if (stream) stream.whenAtGround(flip);
|
|
761
|
+
else flip();
|
|
738
762
|
}
|
|
739
763
|
|
|
740
764
|
// Send a full reset: a control frame with current geometry/status, then the serialized
|
|
741
|
-
// emulator state as a raw byte frame. Used on connect, resume, and refresh.
|
|
742
|
-
|
|
765
|
+
// emulator state as a raw byte frame. Used on connect, resume, and refresh. On resume /
|
|
766
|
+
// refresh the live stream is flowing to this socket, so `gateGround` defers the
|
|
767
|
+
// reset+content until a sequence boundary — otherwise the reset would splice between the
|
|
768
|
+
// halves of a live escape sequence and orphan its tail on the client (#276).
|
|
769
|
+
async function sendBackfill(ws: TerminalSocket, cols?: number, rows?: number, gateGround = false): Promise<void> {
|
|
743
770
|
const stream = ws.data.stream;
|
|
744
771
|
if (!stream) return;
|
|
745
772
|
const snapshot = await stream.backfill(cols, rows);
|
|
746
773
|
if (ws.data.stream !== stream) return; // socket torn down mid-backfill
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
774
|
+
const emit = () => {
|
|
775
|
+
if (ws.data.stream !== stream) return;
|
|
776
|
+
ws.send(JSON.stringify({
|
|
777
|
+
type: "reset",
|
|
778
|
+
session: snapshot.session,
|
|
779
|
+
running: snapshot.running,
|
|
780
|
+
agentAlive: snapshot.agentAlive,
|
|
781
|
+
cols: snapshot.cols,
|
|
782
|
+
rows: snapshot.rows,
|
|
783
|
+
capturedAt: snapshot.capturedAt,
|
|
784
|
+
}));
|
|
785
|
+
if (snapshot.content) {
|
|
786
|
+
ws.send(new TextEncoder().encode(snapshot.content));
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
if (gateGround) await new Promise<void>((resolve) => stream.whenAtGround(() => { emit(); resolve(); }));
|
|
790
|
+
else emit();
|
|
759
791
|
}
|
|
760
792
|
|
|
761
793
|
function handleTerminalSocketMessage(ws: TerminalSocket, data: string | Buffer): void {
|
|
@@ -786,11 +818,23 @@ function handleTerminalSocketMessage(ws: TerminalSocket, data: string | Buffer):
|
|
|
786
818
|
ws.data.stream?.resize(cols, rows);
|
|
787
819
|
}
|
|
788
820
|
} else if (frame.type === "pause") {
|
|
821
|
+
const wasPaused = ws.data.paused === true;
|
|
789
822
|
ws.data.paused = frame.paused === true;
|
|
790
|
-
//
|
|
791
|
-
|
|
823
|
+
// On resume: only re-backfill if bytes were actually dropped while paused. A
|
|
824
|
+
// focus flick (alt-tab) pauses+resumes with zero output in between — re-backfill
|
|
825
|
+
// there needlessly wipes scrollback and scroll position (#272). When nothing was
|
|
826
|
+
// dropped the client's grid already matches tmux, so just resume the live stream.
|
|
827
|
+
if (wasPaused && !ws.data.paused && ws.data.synced) {
|
|
828
|
+
const dropped = ws.data.droppedWhilePaused ?? 0;
|
|
829
|
+
ws.data.droppedWhilePaused = 0;
|
|
830
|
+
if (dropped > 0) void sendBackfill(ws, undefined, undefined, true);
|
|
831
|
+
}
|
|
792
832
|
} else if (frame.type === "refresh") {
|
|
793
|
-
if (ws.data.synced) void sendBackfill(ws);
|
|
833
|
+
if (ws.data.synced) void sendBackfill(ws, undefined, undefined, true);
|
|
834
|
+
} else if (frame.type === "interactive") {
|
|
835
|
+
// Read-only watchers must not reflow the shared tmux window (#273); the typist owns
|
|
836
|
+
// sizing. The client reports its interactivity so the stream knows who may resize.
|
|
837
|
+
ws.data.stream?.setInteractive(frame.interactive === true);
|
|
794
838
|
}
|
|
795
839
|
} catch (e) {
|
|
796
840
|
ws.send(JSON.stringify({ type: "error", error: errMessage(e) }));
|
package/src/control.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { OrchestratorConfig } from "./config";
|
|
|
3
3
|
import type { ManagedAgentReport, RelayClient, RelayCommand } from "./relay";
|
|
4
4
|
import { handleSelfUpgrade } from "./self-upgrade";
|
|
5
5
|
import { spawnAgent, stopSession, type SpawnOptions } from "./spawn";
|
|
6
|
-
import { cleanupWorkspace, mergeWorkspace, pruneWorktrees, reconcileWorkspace, refreshWorkspaceDeps } from "./workspace-probe";
|
|
6
|
+
import { cleanupWorkspace, mergeWorkspace, pruneWorktrees, reconcileWorkspace, refreshWorkspaceDeps, workspacesRoot } from "./workspace-probe";
|
|
7
7
|
|
|
8
8
|
interface ControlHandler {
|
|
9
9
|
handleCommand(command: RelayCommand): Promise<boolean>;
|
|
@@ -94,6 +94,7 @@ export function createControlHandler(
|
|
|
94
94
|
worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
|
|
95
95
|
branch: typeof command.params.branch === "string" ? command.params.branch : undefined,
|
|
96
96
|
deleteBranch: command.params.deleteBranch !== false,
|
|
97
|
+
workspacesRoot: workspacesRoot(config.baseDir),
|
|
97
98
|
});
|
|
98
99
|
await relay.updateCommand(command.id, "succeeded", result);
|
|
99
100
|
} else if (command.type === "workspace.reconcile") {
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { diagnoseSessionExit, hydrateTerminalGuests, isSessionAlive, reapTermina
|
|
|
7
7
|
import { startApiServer } from "./api";
|
|
8
8
|
import { recoverManagedAgents } from "./recovery";
|
|
9
9
|
import { ProviderProbeCache } from "./provider-probe";
|
|
10
|
+
import { sweepEmptyWorkspaceContainers, workspacesRoot } from "./workspace-probe";
|
|
10
11
|
|
|
11
12
|
const args = process.argv.slice(2);
|
|
12
13
|
|
|
@@ -77,6 +78,10 @@ async function startup(): Promise<void> {
|
|
|
77
78
|
// Recover existing tmux sessions
|
|
78
79
|
await recoverManagedAgents(config, control, relay);
|
|
79
80
|
|
|
81
|
+
// Sweep empty workspace container dirs left behind by prior cleanups (#280).
|
|
82
|
+
const swept = sweepEmptyWorkspaceContainers(workspacesRoot(config.baseDir));
|
|
83
|
+
if (swept.length > 0) console.error(`[orchestrator] Swept ${swept.length} empty workspace container(s)`);
|
|
84
|
+
|
|
80
85
|
// Restore guest-terminal TTLs persisted before the last restart, then reap any
|
|
81
86
|
// that expired (or were orphaned) while the orchestrator was down (#144).
|
|
82
87
|
hydrateTerminalGuests();
|
package/src/spawn.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
|
4
4
|
import { artifactProxyBaseUrl } from "./artifact-proxy";
|
|
5
5
|
import type { OrchestratorConfig } from "./config";
|
|
6
6
|
import type { ManagedAgentReport, ManagedSessionExitDiagnostics } from "./relay";
|
|
7
|
-
import { resolveSpawnWorkspace } from "./workspace-probe";
|
|
7
|
+
import { resolveSpawnWorkspace, workspacesRoot } from "./workspace-probe";
|
|
8
8
|
import type { WorkspaceMetadata, WorkspaceMode } from "agent-relay-sdk";
|
|
9
9
|
import { errMessage } from "agent-relay-sdk";
|
|
10
10
|
import { isPidAlive, parseProcStateIsZombie } from "agent-relay-sdk/process-utils";
|
|
@@ -350,7 +350,7 @@ export async function spawnAgent(
|
|
|
350
350
|
...opts,
|
|
351
351
|
label,
|
|
352
352
|
workspaceSymlinks: opts.workspaceSymlinks,
|
|
353
|
-
workspaceRoot:
|
|
353
|
+
workspaceRoot: workspacesRoot(config.baseDir),
|
|
354
354
|
});
|
|
355
355
|
const spawnOpts = { ...opts, label, agentId, cwd: resolvedWorkspace.cwd, workspace: resolvedWorkspace.workspace };
|
|
356
356
|
|
package/src/terminal-stream.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// its separate `tmux send-keys` calls and is unaffected — control mode is just
|
|
26
26
|
// another observing client.
|
|
27
27
|
|
|
28
|
-
import {
|
|
28
|
+
import { sessionLiveness, tmuxCommand, tmuxSocketForSession, type TerminalSnapshot } from "./spawn";
|
|
29
29
|
import type { OrchestratorConfig } from "./config";
|
|
30
30
|
import { errMessage } from "agent-relay-sdk";
|
|
31
31
|
|
|
@@ -46,6 +46,29 @@ const RESIZE_SETTLE_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_RES
|
|
|
46
46
|
// streams still correct. 0 disables the corrector.
|
|
47
47
|
const RESYNC_DEBOUNCE_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_RESYNC_DEBOUNCE_MS) || 120);
|
|
48
48
|
const RESYNC_MAX_INTERVAL_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_RESYNC_MAX_INTERVAL_MS) || 350);
|
|
49
|
+
// When a resync falls due mid-escape-sequence we defer it (ground-state gate, #276) and
|
|
50
|
+
// re-check after this short interval until the stream reaches a sequence boundary.
|
|
51
|
+
const RESYNC_GROUND_RETRY_MS = Math.max(1, Number(process.env.AGENT_RELAY_TERMINAL_RESYNC_GROUND_RETRY_MS) || 16);
|
|
52
|
+
// Hard cap on ground-state deferral: if the stream sits mid-sequence this long (a stalled
|
|
53
|
+
// or dead pane — rare), inject the repaint anyway, CAN-prefixed to abort the client's
|
|
54
|
+
// half-parsed sequence. The orphan-tail risk is accepted in that degenerate case (#276).
|
|
55
|
+
const RESYNC_GROUND_DEFER_MAX_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_RESYNC_GROUND_DEFER_MAX_MS) || 500);
|
|
56
|
+
// Per-command reply timeout for the in-band control protocol. A reply that never lands
|
|
57
|
+
// means a desync; we reject and reset the reply queue so the next command starts clean.
|
|
58
|
+
const COMMAND_TIMEOUT_MS = Math.max(100, Number(process.env.AGENT_RELAY_TERMINAL_COMMAND_TIMEOUT_MS) || 2000);
|
|
59
|
+
// Upper bound a backfill/ready-flip waits for the live stream to reach a sequence boundary
|
|
60
|
+
// before proceeding anyway (whenAtGround fallback for a stalled stream).
|
|
61
|
+
const GROUND_WAIT_MAX_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_GROUND_WAIT_MAX_MS) || 500);
|
|
62
|
+
// CAN (cancel) aborts a half-parsed sequence on the client.
|
|
63
|
+
const CAN_BYTE = 0x18;
|
|
64
|
+
|
|
65
|
+
interface PendingCommand {
|
|
66
|
+
wantReply: boolean;
|
|
67
|
+
lines: string[];
|
|
68
|
+
resolve: (lines: string[]) => void;
|
|
69
|
+
reject: (err: Error) => void;
|
|
70
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
71
|
+
}
|
|
49
72
|
// On attach we include this many lines of tmux scrollback ABOVE the current screen so the
|
|
50
73
|
// viewer can scroll back through pre-attach history (the client lands on the live screen;
|
|
51
74
|
// the history sits in its scroll buffer). This is a one-time per-attach paint, so it's
|
|
@@ -67,6 +90,14 @@ export interface TerminalStreamHandle {
|
|
|
67
90
|
backfill(cols?: number, rows?: number): Promise<TerminalSnapshot>;
|
|
68
91
|
write(bytes: Uint8Array): void;
|
|
69
92
|
resize(cols: number, rows: number): void;
|
|
93
|
+
// Declare whether this viewer is interactive (typing). Only the interactive viewer
|
|
94
|
+
// sizes the shared tmux window, so read-only watchers can't reflow a working
|
|
95
|
+
// terminal by attaching/refreshing at their own width (#273).
|
|
96
|
+
setInteractive(interactive: boolean): void;
|
|
97
|
+
// Run `cb` when the outbound stream is at an ANSI sequence boundary (or after a short
|
|
98
|
+
// fallback). Used to gate reset/backfill injection so it can't split a live escape
|
|
99
|
+
// sequence on the client (#276).
|
|
100
|
+
whenAtGround(cb: () => void): void;
|
|
70
101
|
release(): void;
|
|
71
102
|
}
|
|
72
103
|
|
|
@@ -116,8 +147,22 @@ export function decodeControlOutput(data: string): Uint8Array {
|
|
|
116
147
|
export type ControlLine =
|
|
117
148
|
| { type: "output"; pane: string; bytes: Uint8Array }
|
|
118
149
|
| { type: "exit"; reason?: string }
|
|
150
|
+
// Command-reply block framing. Every command written to the control client's stdin is
|
|
151
|
+
// answered, in order, with a `%begin … %end` (or `%error`) block carrying the same
|
|
152
|
+
// command number on both ends. The content lines between them are the command's output
|
|
153
|
+
// (e.g. capture-pane grid rows). Correlating these in-band serializes captures with
|
|
154
|
+
// `%output` deltas — the ordering guarantee that kills the duplicate-text race (#275).
|
|
155
|
+
| { type: "begin"; num: number }
|
|
156
|
+
| { type: "end"; num: number }
|
|
157
|
+
| { type: "error"; num: number }
|
|
119
158
|
| { type: "other" };
|
|
120
159
|
|
|
160
|
+
// `%begin <timestamp> <cmd-number> <flags>` → the command number is the 2nd field.
|
|
161
|
+
function parseBlockNum(line: string, prefixLen: number): number {
|
|
162
|
+
const n = Number(line.slice(prefixLen).trim().split(/\s+/)[1]);
|
|
163
|
+
return Number.isFinite(n) ? n : -1;
|
|
164
|
+
}
|
|
165
|
+
|
|
121
166
|
export function parseControlLine(line: string): ControlLine {
|
|
122
167
|
if (line.startsWith("%output ")) {
|
|
123
168
|
const rest = line.slice(8);
|
|
@@ -126,6 +171,9 @@ export function parseControlLine(line: string): ControlLine {
|
|
|
126
171
|
const data = sp === -1 ? "" : rest.slice(sp + 1);
|
|
127
172
|
return { type: "output", pane, bytes: decodeControlOutput(data) };
|
|
128
173
|
}
|
|
174
|
+
if (line.startsWith("%begin ")) return { type: "begin", num: parseBlockNum(line, 7) };
|
|
175
|
+
if (line.startsWith("%end ")) return { type: "end", num: parseBlockNum(line, 5) };
|
|
176
|
+
if (line.startsWith("%error ")) return { type: "error", num: parseBlockNum(line, 7) };
|
|
129
177
|
if (line === "%exit" || line.startsWith("%exit ")) {
|
|
130
178
|
const reason = line.slice(5).trim();
|
|
131
179
|
return { type: "exit", ...(reason ? { reason } : {}) };
|
|
@@ -133,6 +181,67 @@ export function parseControlLine(line: string): ControlLine {
|
|
|
133
181
|
return { type: "other" };
|
|
134
182
|
}
|
|
135
183
|
|
|
184
|
+
// --- ANSI ground-state tracker (unit-tested) ---
|
|
185
|
+
//
|
|
186
|
+
// tmux chunks `%output` at pty-read / flush boundaries that can land mid-escape-sequence.
|
|
187
|
+
// That's fine for xterm (its parser is stateful across writes), but if we splice an
|
|
188
|
+
// out-of-band repaint (resync) BETWEEN the two halves of a split sequence, the injected
|
|
189
|
+
// ESC aborts the half-parsed CSI and the sequence's tail bytes then arrive in ground state
|
|
190
|
+
// and print as literal text — the stray `S` (final byte of `CSI Ps S`, scroll-up, which
|
|
191
|
+
// scroll-region TUIs like Claude Code emit constantly). So we track whether the outbound
|
|
192
|
+
// stream is at a sequence boundary (ground) and only inject there. This is a deliberately
|
|
193
|
+
// minimal VT state machine: enough to know "are we mid-sequence?", not a full parser.
|
|
194
|
+
export type AnsiState = "ground" | "esc" | "esc-charset" | "csi" | "string" | "string-esc";
|
|
195
|
+
|
|
196
|
+
export function advanceAnsiState(state: AnsiState, byte: number): AnsiState {
|
|
197
|
+
// CAN (0x18) / SUB (0x1a) abort any in-progress sequence from any state → ground.
|
|
198
|
+
if (byte === 0x18 || byte === 0x1a) return "ground";
|
|
199
|
+
switch (state) {
|
|
200
|
+
case "ground":
|
|
201
|
+
if (byte === 0x1b) return "esc";
|
|
202
|
+
if (byte === 0x9b) return "csi"; // C1 CSI
|
|
203
|
+
if (byte === 0x9d || byte === 0x90) return "string"; // C1 OSC / DCS
|
|
204
|
+
return "ground"; // text, UTF-8 continuation bytes, lone C1 ST, etc.
|
|
205
|
+
case "esc":
|
|
206
|
+
if (byte === 0x5b) return "csi"; // '[' → CSI
|
|
207
|
+
if (byte === 0x5d || byte === 0x50 || byte === 0x58 || byte === 0x5e || byte === 0x5f)
|
|
208
|
+
return "string"; // ']' OSC, 'P' DCS, 'X' SOS, '^' PM, '_' APC → string until ST/BEL
|
|
209
|
+
if (byte >= 0x28 && byte <= 0x2b) return "esc-charset"; // ( ) * + → next byte designates a charset
|
|
210
|
+
if (byte >= 0x20 && byte <= 0x2f) return "esc"; // other intermediate, keep collecting
|
|
211
|
+
return "ground"; // final byte of a 2-byte escape (ESC M, ESC 7, ESC =, …)
|
|
212
|
+
case "esc-charset":
|
|
213
|
+
return "ground"; // the single charset-designator byte
|
|
214
|
+
case "csi":
|
|
215
|
+
if (byte === 0x1b) return "esc"; // ESC cancels and restarts a sequence
|
|
216
|
+
if (byte >= 0x40 && byte <= 0x7e) return "ground"; // final byte ends the CSI
|
|
217
|
+
return "csi"; // parameter (0x30–0x3f) / intermediate (0x20–0x2f) / executed C0
|
|
218
|
+
case "string":
|
|
219
|
+
if (byte === 0x07 || byte === 0x9c) return "ground"; // BEL or C1 ST terminates
|
|
220
|
+
if (byte === 0x1b) return "string-esc"; // possible 7-bit ST (ESC \)
|
|
221
|
+
return "string";
|
|
222
|
+
case "string-esc":
|
|
223
|
+
if (byte === 0x5c) return "ground"; // ST: ESC \
|
|
224
|
+
if (byte === 0x1b) return "string-esc";
|
|
225
|
+
return "string"; // stray ESC inside the string — stay in string mode
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Fold a chunk of outbound bytes into the running ANSI state. Returns the state AFTER the
|
|
230
|
+
// chunk, so the caller knows whether the stream currently sits at a sequence boundary.
|
|
231
|
+
export function scanAnsiState(bytes: Uint8Array, state: AnsiState = "ground"): AnsiState {
|
|
232
|
+
for (let i = 0; i < bytes.length; i++) state = advanceAnsiState(state, bytes[i]!);
|
|
233
|
+
return state;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Parse a `#{pane_width} #{pane_height}` reply line into positive dims (omits non-finite).
|
|
237
|
+
export function parsePaneDims(line: string): { cols?: number; rows?: number } {
|
|
238
|
+
const [w, h] = line.trim().split(/\s+/).map(Number);
|
|
239
|
+
return {
|
|
240
|
+
...(w !== undefined && Number.isFinite(w) && w > 0 ? { cols: w } : {}),
|
|
241
|
+
...(h !== undefined && Number.isFinite(h) && h > 0 ? { rows: h } : {}),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
136
245
|
// Encode raw input bytes for `send-keys -H` (space-separated hex octets).
|
|
137
246
|
export function encodeSendKeysHex(bytes: Uint8Array): string {
|
|
138
247
|
return Array.from(bytes)
|
|
@@ -196,6 +305,24 @@ class SessionStream {
|
|
|
196
305
|
private resyncTimer: ReturnType<typeof setTimeout> | null = null;
|
|
197
306
|
private resyncCapTimer: ReturnType<typeof setTimeout> | null = null;
|
|
198
307
|
private resyncDirty = false;
|
|
308
|
+
// Running ANSI parse state of the outbound (live) stream. A resync repaint or backfill
|
|
309
|
+
// reset is only injected when this is "ground" (a sequence boundary), so it can't split
|
|
310
|
+
// an escape sequence. Tracked over live flush bytes only — injected repaints are balanced.
|
|
311
|
+
private broadcastState: AnsiState = "ground";
|
|
312
|
+
private groundWaiters: Array<() => void> = [];
|
|
313
|
+
// The viewer that owns window sizing (the interactive typist). While set, only it may
|
|
314
|
+
// resize the shared tmux window; read-only watchers render at the current pane size.
|
|
315
|
+
private sizingOwner: TerminalStreamSubscriber | null = null;
|
|
316
|
+
// In-band command-reply correlation (#275): one FIFO entry per command written to the
|
|
317
|
+
// control client's stdin; tmux answers each with a %begin…%end (or %error) block, in
|
|
318
|
+
// order. The first block on attach is unsolicited (discarded via attachBlockSeen).
|
|
319
|
+
private pendingCommands: PendingCommand[] = [];
|
|
320
|
+
private currentBlock: { num: number; lines: string[] } | null = null;
|
|
321
|
+
private attachBlockSeen = false;
|
|
322
|
+
// Resync runs an async in-band capture; guard against overlapping captures and track how
|
|
323
|
+
// long the ground gate has been deferring (for the CAN fallback).
|
|
324
|
+
private resyncInFlight = false;
|
|
325
|
+
private groundDeferStart = 0;
|
|
199
326
|
|
|
200
327
|
constructor(
|
|
201
328
|
private readonly session: string,
|
|
@@ -209,8 +336,9 @@ class SessionStream {
|
|
|
209
336
|
const socket = tmuxSocketForSession(this.session);
|
|
210
337
|
this.socket = socket;
|
|
211
338
|
// Pin the window size before attaching so the control client can't reflow the
|
|
212
|
-
// pane (default window-size would shrink it to the new client's 80x24).
|
|
213
|
-
|
|
339
|
+
// pane (default window-size would shrink it to the new client's 80x24). These are
|
|
340
|
+
// one-time, pre-attach spawnSyncs (the control client isn't up yet) — fine to block.
|
|
341
|
+
const dims = this.paneDimsSync();
|
|
214
342
|
if (dims.cols) this.termCols = dims.cols;
|
|
215
343
|
if (dims.rows) this.termRows = dims.rows;
|
|
216
344
|
Bun.spawnSync(tmuxCommand(socket, "set-window-option", "-t", this.session, "window-size", "manual"), {
|
|
@@ -262,12 +390,45 @@ class SessionStream {
|
|
|
262
390
|
}
|
|
263
391
|
|
|
264
392
|
private handleLine(line: string): void {
|
|
393
|
+
// Inside a reply block, every line is the command's output until its OWN %end/%error
|
|
394
|
+
// (matched by command number — a captured grid row could otherwise masquerade as one).
|
|
395
|
+
// `%output` notifications never appear inside a block, so this can't swallow live deltas.
|
|
396
|
+
if (this.currentBlock) {
|
|
397
|
+
const parsed = parseControlLine(line);
|
|
398
|
+
if ((parsed.type === "end" || parsed.type === "error") && parsed.num === this.currentBlock.num) {
|
|
399
|
+
this.finishBlock(parsed.type === "error");
|
|
400
|
+
} else {
|
|
401
|
+
this.currentBlock.lines.push(line);
|
|
402
|
+
}
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
265
405
|
const parsed = parseControlLine(line);
|
|
266
|
-
if (parsed.type === "
|
|
406
|
+
if (parsed.type === "begin") {
|
|
407
|
+
this.currentBlock = { num: parsed.num, lines: [] };
|
|
408
|
+
} else if (parsed.type === "output") {
|
|
267
409
|
this.enqueue(parsed.bytes);
|
|
268
410
|
} else if (parsed.type === "exit") {
|
|
269
411
|
this.fail(parsed.reason ? `tmux exit: ${parsed.reason}` : "tmux exit");
|
|
270
412
|
}
|
|
413
|
+
// A stray %end/%error with no open block, or any %other notification — ignore.
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Resolve the just-closed reply block against the FIFO head. The very first block on
|
|
417
|
+
// control-mode attach is unsolicited (tmux's empty initial command) — discard it so the
|
|
418
|
+
// correlation never drifts off by one.
|
|
419
|
+
private finishBlock(isError: boolean): void {
|
|
420
|
+
const block = this.currentBlock;
|
|
421
|
+
this.currentBlock = null;
|
|
422
|
+
if (!block) return;
|
|
423
|
+
if (!this.attachBlockSeen) {
|
|
424
|
+
this.attachBlockSeen = true;
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const entry = this.pendingCommands.shift();
|
|
428
|
+
if (!entry) return; // unexpected extra block — drop it rather than mis-correlate
|
|
429
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
430
|
+
if (isError) entry.reject(new Error(block.lines.join(" ").trim() || "tmux command error"));
|
|
431
|
+
else entry.resolve(block.lines);
|
|
271
432
|
}
|
|
272
433
|
|
|
273
434
|
private enqueue(bytes: Uint8Array): void {
|
|
@@ -297,7 +458,11 @@ class SessionStream {
|
|
|
297
458
|
}
|
|
298
459
|
this.pending = [];
|
|
299
460
|
this.pendingBytes = 0;
|
|
461
|
+
// Track ANSI ground state over LIVE bytes only (injected repaints are balanced by
|
|
462
|
+
// construction). The resync gate and whenAtGround read this to know it's safe to inject.
|
|
463
|
+
this.broadcastState = scanAnsiState(merged, this.broadcastState);
|
|
300
464
|
this.broadcast(merged);
|
|
465
|
+
if (this.broadcastState === "ground") this.fireGroundWaiters();
|
|
301
466
|
// Live deltas just went out; schedule an authoritative resync to correct any drift.
|
|
302
467
|
this.scheduleResync();
|
|
303
468
|
}
|
|
@@ -317,19 +482,106 @@ class SessionStream {
|
|
|
317
482
|
}
|
|
318
483
|
}
|
|
319
484
|
|
|
485
|
+
// Run `cb` at the next ANSI sequence boundary on the live stream (or now, if already at
|
|
486
|
+
// one), so an injected reset/backfill can't splice between the halves of a live escape
|
|
487
|
+
// sequence (#276). Falls back to firing anyway after GROUND_WAIT_MAX_MS if the stream
|
|
488
|
+
// stalls mid-sequence (rare — a dead pane); the reset path does a full clear regardless.
|
|
489
|
+
whenAtGround(cb: () => void): void {
|
|
490
|
+
if (this.closed || this.broadcastState === "ground") {
|
|
491
|
+
cb();
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
let fired = false;
|
|
495
|
+
const fire = () => {
|
|
496
|
+
if (fired) return;
|
|
497
|
+
fired = true;
|
|
498
|
+
clearTimeout(timer);
|
|
499
|
+
const i = this.groundWaiters.indexOf(waiter);
|
|
500
|
+
if (i !== -1) this.groundWaiters.splice(i, 1);
|
|
501
|
+
try {
|
|
502
|
+
cb();
|
|
503
|
+
} catch {}
|
|
504
|
+
};
|
|
505
|
+
const waiter = fire;
|
|
506
|
+
const timer = setTimeout(fire, GROUND_WAIT_MAX_MS);
|
|
507
|
+
this.groundWaiters.push(waiter);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private fireGroundWaiters(): void {
|
|
511
|
+
if (this.groundWaiters.length === 0) return;
|
|
512
|
+
const waiters = this.groundWaiters;
|
|
513
|
+
this.groundWaiters = [];
|
|
514
|
+
for (const w of waiters) {
|
|
515
|
+
try {
|
|
516
|
+
w();
|
|
517
|
+
} catch {}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
320
521
|
// Schedule a drift-correcting resync: a trailing debounce fires once output settles,
|
|
321
522
|
// and a capped interval guarantees correction during continuous output.
|
|
322
523
|
private scheduleResync(): void {
|
|
323
524
|
if (RESYNC_DEBOUNCE_MS <= 0 || this.subscribers.size === 0) return;
|
|
324
525
|
this.resyncDirty = true;
|
|
325
526
|
if (this.resyncTimer) clearTimeout(this.resyncTimer);
|
|
326
|
-
this.resyncTimer = setTimeout(() => this.doResync(), RESYNC_DEBOUNCE_MS);
|
|
527
|
+
this.resyncTimer = setTimeout(() => void this.doResync(), RESYNC_DEBOUNCE_MS);
|
|
327
528
|
if (!this.resyncCapTimer && RESYNC_MAX_INTERVAL_MS > 0) {
|
|
328
|
-
this.resyncCapTimer = setTimeout(() => this.doResync(), RESYNC_MAX_INTERVAL_MS);
|
|
529
|
+
this.resyncCapTimer = setTimeout(() => void this.doResync(), RESYNC_MAX_INTERVAL_MS);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private async doResync(): Promise<void> {
|
|
534
|
+
this.clearResyncTimers();
|
|
535
|
+
if (!this.resyncDirty || this.closed || this.subscribers.size === 0) return;
|
|
536
|
+
// One in-band capture at a time; the next flush re-arms us.
|
|
537
|
+
if (this.resyncInFlight) {
|
|
538
|
+
this.resyncTimer = setTimeout(() => void this.doResync(), RESYNC_GROUND_RETRY_MS);
|
|
539
|
+
return;
|
|
329
540
|
}
|
|
541
|
+
// Ground-state gate (#276): never splice a repaint between the two halves of a split
|
|
542
|
+
// escape sequence (the injected ESC aborts the half-parsed CSI and its tail prints as
|
|
543
|
+
// literal text — the stray "S"). If we're mid-sequence, keep the work pending and
|
|
544
|
+
// re-check shortly. If the stream stays mid-sequence past the hard cap (stalled pane),
|
|
545
|
+
// force the repaint with a CAN prefix to abort the client's half-sequence.
|
|
546
|
+
let forceAbort = false;
|
|
547
|
+
if (this.broadcastState !== "ground") {
|
|
548
|
+
const now = Date.now();
|
|
549
|
+
if (this.groundDeferStart === 0) this.groundDeferStart = now;
|
|
550
|
+
if (now - this.groundDeferStart < RESYNC_GROUND_DEFER_MAX_MS) {
|
|
551
|
+
this.resyncTimer = setTimeout(() => void this.doResync(), RESYNC_GROUND_RETRY_MS);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
forceAbort = true;
|
|
555
|
+
}
|
|
556
|
+
this.groundDeferStart = 0;
|
|
557
|
+
this.resyncDirty = false;
|
|
558
|
+
this.resyncInFlight = true;
|
|
559
|
+
try {
|
|
560
|
+
const repaint = await this.resyncRepaint();
|
|
561
|
+
if (!repaint || this.closed || this.subscribers.size === 0) return;
|
|
562
|
+
// Ordering (#275): every %output read before the capture's %end is already in
|
|
563
|
+
// `pending`; drain it to subscribers BEFORE the repaint so scrolled lines can't
|
|
564
|
+
// re-apply on top of it (the duplicate-text race). Deltas after %end describe
|
|
565
|
+
// post-capture changes and correctly apply on top of the repaint next flush.
|
|
566
|
+
this.flush();
|
|
567
|
+
const out = forceAbort ? this.prependCan(repaint) : repaint;
|
|
568
|
+
this.broadcast(out);
|
|
569
|
+
tdbg(`resync ${this.session} bytes=${out.length} viewers=${this.subscribers.size}${forceAbort ? " (forced)" : ""}`);
|
|
570
|
+
} catch (e) {
|
|
571
|
+
tdbg(`resync ${this.session} failed: ${errMessage(e)}`);
|
|
572
|
+
} finally {
|
|
573
|
+
this.resyncInFlight = false;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private prependCan(bytes: Uint8Array): Uint8Array {
|
|
578
|
+
const out = new Uint8Array(bytes.length + 1);
|
|
579
|
+
out[0] = CAN_BYTE;
|
|
580
|
+
out.set(bytes, 1);
|
|
581
|
+
return out;
|
|
330
582
|
}
|
|
331
583
|
|
|
332
|
-
private
|
|
584
|
+
private clearResyncTimers(): void {
|
|
333
585
|
if (this.resyncTimer) {
|
|
334
586
|
clearTimeout(this.resyncTimer);
|
|
335
587
|
this.resyncTimer = null;
|
|
@@ -338,66 +590,61 @@ class SessionStream {
|
|
|
338
590
|
clearTimeout(this.resyncCapTimer);
|
|
339
591
|
this.resyncCapTimer = null;
|
|
340
592
|
}
|
|
341
|
-
if (!this.resyncDirty || this.closed) return;
|
|
342
|
-
this.resyncDirty = false;
|
|
343
|
-
if (this.subscribers.size === 0) return;
|
|
344
|
-
const repaint = this.resyncRepaint();
|
|
345
|
-
if (!repaint) return;
|
|
346
|
-
this.broadcast(repaint);
|
|
347
|
-
tdbg(`resync ${this.session} bytes=${repaint.length} viewers=${this.subscribers.size}`);
|
|
348
593
|
}
|
|
349
594
|
|
|
350
595
|
// Build an in-place authoritative repaint from tmux's grid: absolute-position each row,
|
|
351
596
|
// reset SGR + clear it, then write tmux's styled cells. This overwrites any drift (a
|
|
352
597
|
// doubled statusline, a solid-instead-of-faded suggestion, stale cells) without a
|
|
353
598
|
// full-screen clear flash, and re-parks the cursor at tmux's true position/visibility.
|
|
354
|
-
|
|
355
|
-
|
|
599
|
+
// The grid + cursor are read in-band through the control client (#275), so the capture
|
|
600
|
+
// is serialized with %output deltas and costs no process spawn.
|
|
601
|
+
private async resyncRepaint(): Promise<Uint8Array | null> {
|
|
602
|
+
const body = await this.readScreen();
|
|
356
603
|
if (!body) return null;
|
|
357
|
-
const cursor = this.readCursorState();
|
|
358
|
-
const
|
|
604
|
+
const cursor = await this.readCursorState();
|
|
605
|
+
const dims = await this.paneDims();
|
|
606
|
+
const rows = dims.rows ?? this.termRows;
|
|
359
607
|
return new TextEncoder().encode(buildInPlaceRepaint(body, rows, cursor));
|
|
360
608
|
}
|
|
361
609
|
|
|
362
|
-
// Read tmux's authoritative
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
private captureScreen(): { content: string; cursorX?: number; cursorY?: number } {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
610
|
+
// Read tmux's authoritative grid (styled) plus a consistent cursor, and turn it into a
|
|
611
|
+
// client repaint. capture-pane reads tmux's real emulator grid, so it's internally
|
|
612
|
+
// coherent; we bracket content/cursor/content (in-band, cheap now) to guard the read
|
|
613
|
+
// against a mid-render change.
|
|
614
|
+
private async captureScreen(): Promise<{ content: string; cursorX?: number; cursorY?: number }> {
|
|
615
|
+
let content = await this.readBackfill();
|
|
616
|
+
let cursor = await this.readCursor();
|
|
617
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
618
|
+
const recheck = await this.readBackfill();
|
|
619
|
+
if (recheck === content) break;
|
|
620
|
+
content = recheck;
|
|
621
|
+
cursor = await this.readCursor();
|
|
622
|
+
}
|
|
371
623
|
return { content: buildScreenRepaint(content, cursor.cursorX, cursor.cursorY), ...cursor };
|
|
372
624
|
}
|
|
373
625
|
|
|
374
626
|
// Backfill capture: current screen plus scrollback history above it (cursor is
|
|
375
627
|
// viewport-relative, so it still parks on the live screen). Falls back to screen-only.
|
|
376
|
-
private readBackfill(): string {
|
|
628
|
+
private async readBackfill(): Promise<string> {
|
|
377
629
|
if (BACKFILL_SCROLLBACK_LINES <= 0) return this.readScreen();
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
);
|
|
382
|
-
return r.exitCode === 0 ? r.stdout.toString("utf8") : this.readScreen();
|
|
630
|
+
const lines = await this.runCommand(
|
|
631
|
+
`capture-pane -p -e -S -${BACKFILL_SCROLLBACK_LINES} -t "${this.session}"`,
|
|
632
|
+
).catch(() => null);
|
|
633
|
+
return lines ? lines.join("\n") : this.readScreen();
|
|
383
634
|
}
|
|
384
635
|
|
|
385
636
|
// Current-screen-only capture (no scrollback) — used by the live resync corrector so it
|
|
386
637
|
// overwrites just the visible grid and leaves the client's scroll-back history intact.
|
|
387
|
-
private readScreen(): string {
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const
|
|
397
|
-
tmuxCommand(this.socket, "display-message", "-p", "-t", this.session, "#{cursor_x} #{cursor_y}"),
|
|
398
|
-
{ stdin: "ignore", stdout: "pipe", stderr: "ignore" },
|
|
399
|
-
);
|
|
400
|
-
const [x, y] = r.stdout.toString().trim().split(/\s+/).map(Number);
|
|
638
|
+
private async readScreen(): Promise<string> {
|
|
639
|
+
const lines = await this.runCommand(`capture-pane -p -e -t "${this.session}"`).catch(() => null);
|
|
640
|
+
return lines ? lines.join("\n") : "";
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private async readCursor(): Promise<{ cursorX?: number; cursorY?: number }> {
|
|
644
|
+
const lines = await this.runCommand(
|
|
645
|
+
`display-message -p -t "${this.session}" "#{cursor_x} #{cursor_y}"`,
|
|
646
|
+
).catch(() => [] as string[]);
|
|
647
|
+
const [x, y] = (lines[0] ?? "").trim().split(/\s+/).map(Number);
|
|
401
648
|
return {
|
|
402
649
|
...(Number.isFinite(x) ? { cursorX: x } : {}),
|
|
403
650
|
...(Number.isFinite(y) ? { cursorY: y } : {}),
|
|
@@ -406,12 +653,11 @@ class SessionStream {
|
|
|
406
653
|
|
|
407
654
|
// Cursor position plus visibility (cursor_flag), so a resync repaint restores whether
|
|
408
655
|
// the TUI had the cursor shown or hidden rather than guessing.
|
|
409
|
-
private readCursorState(): { cursorX?: number; cursorY?: number; visible?: boolean } {
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
);
|
|
414
|
-
const [x, y, flag] = r.stdout.toString().trim().split(/\s+/);
|
|
656
|
+
private async readCursorState(): Promise<{ cursorX?: number; cursorY?: number; visible?: boolean }> {
|
|
657
|
+
const lines = await this.runCommand(
|
|
658
|
+
`display-message -p -t "${this.session}" "#{cursor_x} #{cursor_y} #{cursor_flag}"`,
|
|
659
|
+
).catch(() => [] as string[]);
|
|
660
|
+
const [x, y, flag] = (lines[0] ?? "").trim().split(/\s+/);
|
|
415
661
|
const cx = Number(x);
|
|
416
662
|
const cy = Number(y);
|
|
417
663
|
return {
|
|
@@ -424,25 +670,22 @@ class SessionStream {
|
|
|
424
670
|
// Repaint a freshly-attached (or resumed/refreshed) viewer from tmux's current grid.
|
|
425
671
|
// When the viewer's dimensions are given we resize the tmux pane first so the TUI
|
|
426
672
|
// re-renders at the viewer's width, let it settle, then capture the post-resize frame.
|
|
427
|
-
async backfill(cols?: number, rows?: number): Promise<TerminalSnapshot> {
|
|
673
|
+
async backfill(sub: TerminalStreamSubscriber, cols?: number, rows?: number): Promise<TerminalSnapshot> {
|
|
428
674
|
let resized = false;
|
|
429
|
-
if (Number.isFinite(cols) && Number.isFinite(rows) && (cols as number) >= 10 && (rows as number) >= 5) {
|
|
675
|
+
if (this.maySize(sub) && Number.isFinite(cols) && Number.isFinite(rows) && (cols as number) >= 10 && (rows as number) >= 5) {
|
|
430
676
|
const c = Math.trunc(cols as number);
|
|
431
677
|
const r = Math.trunc(rows as number);
|
|
432
678
|
if (c !== this.termCols || r !== this.termRows) {
|
|
433
|
-
|
|
434
|
-
tmuxCommand(this.socket, "resize-window", "-t", this.session, "-x", String(c), "-y", String(r)),
|
|
435
|
-
{ stdin: "ignore", stdout: "ignore", stderr: "ignore" },
|
|
436
|
-
);
|
|
679
|
+
void this.command(`resize-window -t "${this.session}" -x ${c} -y ${r}`);
|
|
437
680
|
this.termCols = c;
|
|
438
681
|
this.termRows = r;
|
|
439
682
|
resized = true;
|
|
440
683
|
}
|
|
441
684
|
}
|
|
442
685
|
if (resized && RESIZE_SETTLE_MS > 0) await Bun.sleep(RESIZE_SETTLE_MS);
|
|
443
|
-
const { content } = this.captureScreen();
|
|
686
|
+
const { content } = await this.captureScreen();
|
|
444
687
|
// Report tmux's actual pane size (authoritative) back to the viewer.
|
|
445
|
-
const dims = this.paneDims();
|
|
688
|
+
const dims = await this.paneDims();
|
|
446
689
|
if (dims.cols) this.termCols = dims.cols;
|
|
447
690
|
if (dims.rows) this.termRows = dims.rows;
|
|
448
691
|
const live = sessionLiveness(this.session);
|
|
@@ -458,45 +701,106 @@ class SessionStream {
|
|
|
458
701
|
};
|
|
459
702
|
}
|
|
460
703
|
|
|
461
|
-
write(bytes: Uint8Array): void {
|
|
704
|
+
write(sub: TerminalStreamSubscriber, bytes: Uint8Array): void {
|
|
462
705
|
if (this.closed || !this.proc || bytes.length === 0) return;
|
|
463
|
-
|
|
706
|
+
// Typing is the strongest interactivity signal — the typist owns window sizing.
|
|
707
|
+
this.sizingOwner = sub;
|
|
708
|
+
void this.command(`send-keys -t "${this.session}" -H ${encodeSendKeysHex(bytes)}`);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
setInteractive(sub: TerminalStreamSubscriber, interactive: boolean): void {
|
|
712
|
+
if (interactive) {
|
|
713
|
+
this.sizingOwner = sub;
|
|
714
|
+
} else if (this.sizingOwner === sub) {
|
|
715
|
+
this.sizingOwner = null;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// A viewer may size the shared window only if it's the sizing owner, or nobody owns
|
|
720
|
+
// sizing yet (first viewer, before anyone has declared interactivity — it still needs
|
|
721
|
+
// a sensible size). Once an interactive viewer exists, read-only watchers never resize.
|
|
722
|
+
private maySize(sub: TerminalStreamSubscriber): boolean {
|
|
723
|
+
return this.sizingOwner === null || this.sizingOwner === sub;
|
|
464
724
|
}
|
|
465
725
|
|
|
466
|
-
resize(cols: number, rows: number): void {
|
|
726
|
+
resize(sub: TerminalStreamSubscriber, cols: number, rows: number): void {
|
|
467
727
|
if (this.closed || !this.proc) return;
|
|
728
|
+
if (!this.maySize(sub)) return; // read-only watcher: don't reflow the shared window
|
|
468
729
|
if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols < 10 || rows < 5) return;
|
|
469
730
|
const c = Math.trunc(cols);
|
|
470
731
|
const r = Math.trunc(rows);
|
|
471
|
-
this.command(`resize-window -t "${this.session}" -x ${c} -y ${r}`);
|
|
732
|
+
void this.command(`resize-window -t "${this.session}" -x ${c} -y ${r}`);
|
|
472
733
|
this.termCols = c;
|
|
473
734
|
this.termRows = r;
|
|
474
735
|
tdbg(`resize ${this.session} -> ${c}x${r} viewers=${this.subscribers.size}`);
|
|
475
736
|
}
|
|
476
737
|
|
|
477
|
-
|
|
738
|
+
// Pane dimensions, in-band through the control client (no spawn).
|
|
739
|
+
private async paneDims(): Promise<{ cols?: number; rows?: number }> {
|
|
740
|
+
const lines = await this.runCommand(
|
|
741
|
+
`display-message -p -t "${this.session}" "#{pane_width} #{pane_height}"`,
|
|
742
|
+
).catch(() => [] as string[]);
|
|
743
|
+
return parsePaneDims(lines[0] ?? "");
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Synchronous pane-dims read for start(), which runs before the control client attaches.
|
|
747
|
+
private paneDimsSync(): { cols?: number; rows?: number } {
|
|
478
748
|
try {
|
|
479
749
|
const out = Bun.spawnSync(
|
|
480
750
|
tmuxCommand(this.socket, "display-message", "-p", "-t", this.session, "#{pane_width} #{pane_height}"),
|
|
481
751
|
{ stdin: "ignore", stdout: "pipe", stderr: "ignore" },
|
|
482
752
|
).stdout.toString().trim();
|
|
483
|
-
|
|
484
|
-
return {
|
|
485
|
-
...(w !== undefined && Number.isFinite(w) && w > 0 ? { cols: w } : {}),
|
|
486
|
-
...(h !== undefined && Number.isFinite(h) && h > 0 ? { rows: h } : {}),
|
|
487
|
-
};
|
|
753
|
+
return parsePaneDims(out);
|
|
488
754
|
} catch {
|
|
489
755
|
return {};
|
|
490
756
|
}
|
|
491
757
|
}
|
|
492
758
|
|
|
493
|
-
|
|
759
|
+
// Run a control command expecting its reply lines (the FIFO correlates them in order).
|
|
760
|
+
private runCommand(line: string): Promise<string[]> {
|
|
761
|
+
return this.command(line, true);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Write a command to the control client's stdin and register a FIFO entry for its reply
|
|
765
|
+
// block. `wantReply` commands resolve with the block's lines (or reject on %error /
|
|
766
|
+
// timeout); fire-and-forget commands resolve with [] once their empty block closes.
|
|
767
|
+
private command(line: string, wantReply = false): Promise<string[]> {
|
|
768
|
+
if (this.closed) return wantReply ? Promise.reject(new Error("terminal stream closed")) : Promise.resolve([]);
|
|
494
769
|
const stdin = this.proc?.stdin;
|
|
495
|
-
if (!stdin || typeof stdin === "number")
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
770
|
+
if (!stdin || typeof stdin === "number") {
|
|
771
|
+
return wantReply ? Promise.reject(new Error("control client unavailable")) : Promise.resolve([]);
|
|
772
|
+
}
|
|
773
|
+
return new Promise<string[]>((resolve, reject) => {
|
|
774
|
+
const entry: PendingCommand = { wantReply, lines: [], resolve, reject, timer: null };
|
|
775
|
+
if (wantReply) entry.timer = setTimeout(() => this.commandTimeout(entry), COMMAND_TIMEOUT_MS);
|
|
776
|
+
this.pendingCommands.push(entry);
|
|
777
|
+
try {
|
|
778
|
+
(stdin as { write(data: string): void; flush?(): void }).write(`${line}\n`);
|
|
779
|
+
(stdin as { flush?(): void }).flush?.();
|
|
780
|
+
} catch (e) {
|
|
781
|
+
const idx = this.pendingCommands.indexOf(entry);
|
|
782
|
+
if (idx !== -1) this.pendingCommands.splice(idx, 1);
|
|
783
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
784
|
+
if (wantReply) reject(e instanceof Error ? e : new Error(String(e)));
|
|
785
|
+
else resolve([]);
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// A reply that never arrived means a protocol desync — reject the awaited command and
|
|
791
|
+
// reset the whole reply queue + block state so the next command starts clean. The byte
|
|
792
|
+
// stream itself keeps flowing; only in-flight captures fail (the resync just skips a beat).
|
|
793
|
+
private commandTimeout(entry: PendingCommand): void {
|
|
794
|
+
if (!this.pendingCommands.includes(entry)) return;
|
|
795
|
+
tdbg(`command timeout ${this.session}; resetting reply queue (${this.pendingCommands.length} pending)`);
|
|
796
|
+
const pend = this.pendingCommands;
|
|
797
|
+
this.pendingCommands = [];
|
|
798
|
+
this.currentBlock = null;
|
|
799
|
+
for (const e of pend) {
|
|
800
|
+
if (e.timer) clearTimeout(e.timer);
|
|
801
|
+
if (e.wantReply) e.reject(new Error("control command timed out"));
|
|
802
|
+
else e.resolve([]);
|
|
803
|
+
}
|
|
500
804
|
}
|
|
501
805
|
|
|
502
806
|
addSubscriber(sub: TerminalStreamSubscriber): void {
|
|
@@ -506,6 +810,7 @@ class SessionStream {
|
|
|
506
810
|
|
|
507
811
|
removeSubscriber(sub: TerminalStreamSubscriber): void {
|
|
508
812
|
if (!this.subscribers.delete(sub)) return;
|
|
813
|
+
if (this.sizingOwner === sub) this.sizingOwner = null;
|
|
509
814
|
tdbg(`detach ${this.session} viewers=${this.subscribers.size}`);
|
|
510
815
|
if (this.subscribers.size === 0) this.destroy();
|
|
511
816
|
}
|
|
@@ -529,14 +834,17 @@ class SessionStream {
|
|
|
529
834
|
clearTimeout(this.flushTimer);
|
|
530
835
|
this.flushTimer = null;
|
|
531
836
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
837
|
+
this.clearResyncTimers();
|
|
838
|
+
// Reject any in-flight in-band commands so awaiters (captures) unwind instead of hanging.
|
|
839
|
+
const pend = this.pendingCommands;
|
|
840
|
+
this.pendingCommands = [];
|
|
841
|
+
this.currentBlock = null;
|
|
842
|
+
for (const e of pend) {
|
|
843
|
+
if (e.timer) clearTimeout(e.timer);
|
|
844
|
+
if (e.wantReply) e.reject(new Error("terminal stream closed"));
|
|
845
|
+
else e.resolve([]);
|
|
539
846
|
}
|
|
847
|
+
this.fireGroundWaiters();
|
|
540
848
|
try {
|
|
541
849
|
this.proc?.kill();
|
|
542
850
|
} catch {}
|
|
@@ -563,9 +871,11 @@ export function acquireTerminalStream(
|
|
|
563
871
|
const active = stream;
|
|
564
872
|
active.addSubscriber(subscriber);
|
|
565
873
|
return {
|
|
566
|
-
backfill: (cols, rows) => active.backfill(cols, rows),
|
|
567
|
-
write: (bytes) => active.write(bytes),
|
|
568
|
-
resize: (cols, rows) => active.resize(cols, rows),
|
|
874
|
+
backfill: (cols, rows) => active.backfill(subscriber, cols, rows),
|
|
875
|
+
write: (bytes) => active.write(subscriber, bytes),
|
|
876
|
+
resize: (cols, rows) => active.resize(subscriber, cols, rows),
|
|
877
|
+
setInteractive: (interactive) => active.setInteractive(subscriber, interactive),
|
|
878
|
+
whenAtGround: (cb) => active.whenAtGround(cb),
|
|
569
879
|
release: () => active.removeSubscriber(subscriber),
|
|
570
880
|
};
|
|
571
881
|
}
|
package/src/workspace-probe.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, type Dirent } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmdirSync, rmSync, statSync, symlinkSync, type Dirent } from "node:fs";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
@@ -127,7 +127,7 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
|
|
|
127
127
|
const repoRoot = probe.repoRoot;
|
|
128
128
|
const baseSha = probe.headSha ?? requireGit(["rev-parse", "HEAD"], repoRoot);
|
|
129
129
|
const branch = await availableBranch(repoRoot, branchName(input, id));
|
|
130
|
-
const workspaceRoot = input.workspaceRoot ? resolve(input.workspaceRoot) :
|
|
130
|
+
const workspaceRoot = input.workspaceRoot ? resolve(input.workspaceRoot) : workspacesRoot(homedir());
|
|
131
131
|
const worktreePath = join(workspaceRoot, repoSlug(repoRoot), id);
|
|
132
132
|
mkdirSync(join(worktreePath, ".."), { recursive: true });
|
|
133
133
|
requireGit(["worktree", "add", "-b", branch, worktreePath, baseSha], repoRoot);
|
|
@@ -471,7 +471,7 @@ export function pruneWorktrees(input: { repoRoot?: string }): { repoRoot: string
|
|
|
471
471
|
return { repoRoot: repo, pruned: true, output: result.stdout.trim() || undefined };
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
-
export function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?: string; id?: string; branch?: string; deleteBranch?: boolean }): { workspaceId?: string; removed: boolean; worktreePath?: string; branchDeleted?: boolean } {
|
|
474
|
+
export function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?: string; id?: string; branch?: string; deleteBranch?: boolean; workspacesRoot?: string }): { workspaceId?: string; removed: boolean; worktreePath?: string; branchDeleted?: boolean; containerRemoved?: boolean } {
|
|
475
475
|
if (!workspace.worktreePath) throw new Error("worktreePath required");
|
|
476
476
|
const path = resolve(workspace.worktreePath);
|
|
477
477
|
const repo = workspace.repoRoot ? resolve(workspace.repoRoot) : path;
|
|
@@ -484,7 +484,38 @@ export function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?:
|
|
|
484
484
|
if (workspace.branch && workspace.deleteBranch !== false) {
|
|
485
485
|
branchDeleted = git(["branch", "-D", workspace.branch], repo).ok;
|
|
486
486
|
}
|
|
487
|
-
|
|
487
|
+
const containerRemoved = workspace.workspacesRoot ? removeEmptyContainer(dirname(path), resolve(workspace.workspacesRoot)) : false;
|
|
488
|
+
return { workspaceId: workspace.id, removed: true, worktreePath: path, branchDeleted, containerRemoved };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function sweepEmptyWorkspaceContainers(wsRoot: string): string[] {
|
|
492
|
+
const root = resolve(wsRoot);
|
|
493
|
+
if (!existsSync(root)) return [];
|
|
494
|
+
const removed: string[] = [];
|
|
495
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
496
|
+
if (!entry.isDirectory()) continue;
|
|
497
|
+
const dir = join(root, entry.name);
|
|
498
|
+
if (readdirSync(dir).length === 0) {
|
|
499
|
+
rmdirSync(dir);
|
|
500
|
+
removed.push(dir);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return removed;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function removeEmptyContainer(container: string, wsRoot: string): boolean {
|
|
507
|
+
try {
|
|
508
|
+
if (!existsSync(container)) return false;
|
|
509
|
+
if (readdirSync(container).length !== 0) return false;
|
|
510
|
+
if (!isDirectChildOf(container, wsRoot)) return false;
|
|
511
|
+
rmdirSync(container);
|
|
512
|
+
return true;
|
|
513
|
+
} catch { return false; }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function isDirectChildOf(child: string, parent: string): boolean {
|
|
517
|
+
const rel = relative(resolve(parent), resolve(child));
|
|
518
|
+
return !!rel && !rel.includes("/") && !rel.startsWith("..") && !isAbsolute(rel);
|
|
488
519
|
}
|
|
489
520
|
|
|
490
521
|
/**
|
|
@@ -1069,6 +1100,10 @@ function nextBranchName(repoRoot: string, branch: string): string {
|
|
|
1069
1100
|
return `${stem}-${Date.now()}`;
|
|
1070
1101
|
}
|
|
1071
1102
|
|
|
1103
|
+
export function workspacesRoot(baseDir: string): string {
|
|
1104
|
+
return join(resolve(baseDir), ".agent-relay", "workspaces");
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1072
1107
|
function repoSlug(repoRoot: string): string {
|
|
1073
1108
|
const hash = createHash("sha1").update(resolve(repoRoot)).digest("hex").slice(0, 10);
|
|
1074
1109
|
return `${safeSegment(basename(repoRoot), 60)}-${hash}`;
|