agent-relay-orchestrator 0.27.1 → 0.28.0
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 +2 -2
- 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 +410 -87
- package/src/workspace-probe.ts +42 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"test": "bun test"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"agent-relay-sdk": "0.2.
|
|
19
|
+
"agent-relay-sdk": "0.2.17"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/bun": "latest",
|
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
|
|
|
@@ -113,11 +144,38 @@ export function decodeControlOutput(data: string): Uint8Array {
|
|
|
113
144
|
return Uint8Array.from(out);
|
|
114
145
|
}
|
|
115
146
|
|
|
147
|
+
// The control stream is read as latin1 so the `%output` octal path above sees faithful
|
|
148
|
+
// bytes. But command-reply blocks (capture-pane grid rows) arrive as raw UTF-8, so their
|
|
149
|
+
// lines reach us as the byte-faithful latin1 representation. Re-decode them to real UTF-8
|
|
150
|
+
// here, or multi-byte glyphs (box-drawing, powerline) double-encode on the next repaint
|
|
151
|
+
// and render as mojibake (#270). Safe to do per line: no UTF-8 continuation byte is
|
|
152
|
+
// 0x0A/0x0D, so the newline split never lands mid-sequence.
|
|
153
|
+
const REPLY_UTF8_DECODER = new TextDecoder("utf-8");
|
|
154
|
+
export function latin1LineToUtf8(line: string): string {
|
|
155
|
+
const bytes = new Uint8Array(line.length);
|
|
156
|
+
for (let i = 0; i < line.length; i++) bytes[i] = line.charCodeAt(i) & 0xff;
|
|
157
|
+
return REPLY_UTF8_DECODER.decode(bytes);
|
|
158
|
+
}
|
|
159
|
+
|
|
116
160
|
export type ControlLine =
|
|
117
161
|
| { type: "output"; pane: string; bytes: Uint8Array }
|
|
118
162
|
| { type: "exit"; reason?: string }
|
|
163
|
+
// Command-reply block framing. Every command written to the control client's stdin is
|
|
164
|
+
// answered, in order, with a `%begin … %end` (or `%error`) block carrying the same
|
|
165
|
+
// command number on both ends. The content lines between them are the command's output
|
|
166
|
+
// (e.g. capture-pane grid rows). Correlating these in-band serializes captures with
|
|
167
|
+
// `%output` deltas — the ordering guarantee that kills the duplicate-text race (#275).
|
|
168
|
+
| { type: "begin"; num: number }
|
|
169
|
+
| { type: "end"; num: number }
|
|
170
|
+
| { type: "error"; num: number }
|
|
119
171
|
| { type: "other" };
|
|
120
172
|
|
|
173
|
+
// `%begin <timestamp> <cmd-number> <flags>` → the command number is the 2nd field.
|
|
174
|
+
function parseBlockNum(line: string, prefixLen: number): number {
|
|
175
|
+
const n = Number(line.slice(prefixLen).trim().split(/\s+/)[1]);
|
|
176
|
+
return Number.isFinite(n) ? n : -1;
|
|
177
|
+
}
|
|
178
|
+
|
|
121
179
|
export function parseControlLine(line: string): ControlLine {
|
|
122
180
|
if (line.startsWith("%output ")) {
|
|
123
181
|
const rest = line.slice(8);
|
|
@@ -126,6 +184,9 @@ export function parseControlLine(line: string): ControlLine {
|
|
|
126
184
|
const data = sp === -1 ? "" : rest.slice(sp + 1);
|
|
127
185
|
return { type: "output", pane, bytes: decodeControlOutput(data) };
|
|
128
186
|
}
|
|
187
|
+
if (line.startsWith("%begin ")) return { type: "begin", num: parseBlockNum(line, 7) };
|
|
188
|
+
if (line.startsWith("%end ")) return { type: "end", num: parseBlockNum(line, 5) };
|
|
189
|
+
if (line.startsWith("%error ")) return { type: "error", num: parseBlockNum(line, 7) };
|
|
129
190
|
if (line === "%exit" || line.startsWith("%exit ")) {
|
|
130
191
|
const reason = line.slice(5).trim();
|
|
131
192
|
return { type: "exit", ...(reason ? { reason } : {}) };
|
|
@@ -133,6 +194,67 @@ export function parseControlLine(line: string): ControlLine {
|
|
|
133
194
|
return { type: "other" };
|
|
134
195
|
}
|
|
135
196
|
|
|
197
|
+
// --- ANSI ground-state tracker (unit-tested) ---
|
|
198
|
+
//
|
|
199
|
+
// tmux chunks `%output` at pty-read / flush boundaries that can land mid-escape-sequence.
|
|
200
|
+
// That's fine for xterm (its parser is stateful across writes), but if we splice an
|
|
201
|
+
// out-of-band repaint (resync) BETWEEN the two halves of a split sequence, the injected
|
|
202
|
+
// ESC aborts the half-parsed CSI and the sequence's tail bytes then arrive in ground state
|
|
203
|
+
// and print as literal text — the stray `S` (final byte of `CSI Ps S`, scroll-up, which
|
|
204
|
+
// scroll-region TUIs like Claude Code emit constantly). So we track whether the outbound
|
|
205
|
+
// stream is at a sequence boundary (ground) and only inject there. This is a deliberately
|
|
206
|
+
// minimal VT state machine: enough to know "are we mid-sequence?", not a full parser.
|
|
207
|
+
export type AnsiState = "ground" | "esc" | "esc-charset" | "csi" | "string" | "string-esc";
|
|
208
|
+
|
|
209
|
+
export function advanceAnsiState(state: AnsiState, byte: number): AnsiState {
|
|
210
|
+
// CAN (0x18) / SUB (0x1a) abort any in-progress sequence from any state → ground.
|
|
211
|
+
if (byte === 0x18 || byte === 0x1a) return "ground";
|
|
212
|
+
switch (state) {
|
|
213
|
+
case "ground":
|
|
214
|
+
if (byte === 0x1b) return "esc";
|
|
215
|
+
if (byte === 0x9b) return "csi"; // C1 CSI
|
|
216
|
+
if (byte === 0x9d || byte === 0x90) return "string"; // C1 OSC / DCS
|
|
217
|
+
return "ground"; // text, UTF-8 continuation bytes, lone C1 ST, etc.
|
|
218
|
+
case "esc":
|
|
219
|
+
if (byte === 0x5b) return "csi"; // '[' → CSI
|
|
220
|
+
if (byte === 0x5d || byte === 0x50 || byte === 0x58 || byte === 0x5e || byte === 0x5f)
|
|
221
|
+
return "string"; // ']' OSC, 'P' DCS, 'X' SOS, '^' PM, '_' APC → string until ST/BEL
|
|
222
|
+
if (byte >= 0x28 && byte <= 0x2b) return "esc-charset"; // ( ) * + → next byte designates a charset
|
|
223
|
+
if (byte >= 0x20 && byte <= 0x2f) return "esc"; // other intermediate, keep collecting
|
|
224
|
+
return "ground"; // final byte of a 2-byte escape (ESC M, ESC 7, ESC =, …)
|
|
225
|
+
case "esc-charset":
|
|
226
|
+
return "ground"; // the single charset-designator byte
|
|
227
|
+
case "csi":
|
|
228
|
+
if (byte === 0x1b) return "esc"; // ESC cancels and restarts a sequence
|
|
229
|
+
if (byte >= 0x40 && byte <= 0x7e) return "ground"; // final byte ends the CSI
|
|
230
|
+
return "csi"; // parameter (0x30–0x3f) / intermediate (0x20–0x2f) / executed C0
|
|
231
|
+
case "string":
|
|
232
|
+
if (byte === 0x07 || byte === 0x9c) return "ground"; // BEL or C1 ST terminates
|
|
233
|
+
if (byte === 0x1b) return "string-esc"; // possible 7-bit ST (ESC \)
|
|
234
|
+
return "string";
|
|
235
|
+
case "string-esc":
|
|
236
|
+
if (byte === 0x5c) return "ground"; // ST: ESC \
|
|
237
|
+
if (byte === 0x1b) return "string-esc";
|
|
238
|
+
return "string"; // stray ESC inside the string — stay in string mode
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Fold a chunk of outbound bytes into the running ANSI state. Returns the state AFTER the
|
|
243
|
+
// chunk, so the caller knows whether the stream currently sits at a sequence boundary.
|
|
244
|
+
export function scanAnsiState(bytes: Uint8Array, state: AnsiState = "ground"): AnsiState {
|
|
245
|
+
for (let i = 0; i < bytes.length; i++) state = advanceAnsiState(state, bytes[i]!);
|
|
246
|
+
return state;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Parse a `#{pane_width} #{pane_height}` reply line into positive dims (omits non-finite).
|
|
250
|
+
export function parsePaneDims(line: string): { cols?: number; rows?: number } {
|
|
251
|
+
const [w, h] = line.trim().split(/\s+/).map(Number);
|
|
252
|
+
return {
|
|
253
|
+
...(w !== undefined && Number.isFinite(w) && w > 0 ? { cols: w } : {}),
|
|
254
|
+
...(h !== undefined && Number.isFinite(h) && h > 0 ? { rows: h } : {}),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
136
258
|
// Encode raw input bytes for `send-keys -H` (space-separated hex octets).
|
|
137
259
|
export function encodeSendKeysHex(bytes: Uint8Array): string {
|
|
138
260
|
return Array.from(bytes)
|
|
@@ -196,6 +318,24 @@ class SessionStream {
|
|
|
196
318
|
private resyncTimer: ReturnType<typeof setTimeout> | null = null;
|
|
197
319
|
private resyncCapTimer: ReturnType<typeof setTimeout> | null = null;
|
|
198
320
|
private resyncDirty = false;
|
|
321
|
+
// Running ANSI parse state of the outbound (live) stream. A resync repaint or backfill
|
|
322
|
+
// reset is only injected when this is "ground" (a sequence boundary), so it can't split
|
|
323
|
+
// an escape sequence. Tracked over live flush bytes only — injected repaints are balanced.
|
|
324
|
+
private broadcastState: AnsiState = "ground";
|
|
325
|
+
private groundWaiters: Array<() => void> = [];
|
|
326
|
+
// The viewer that owns window sizing (the interactive typist). While set, only it may
|
|
327
|
+
// resize the shared tmux window; read-only watchers render at the current pane size.
|
|
328
|
+
private sizingOwner: TerminalStreamSubscriber | null = null;
|
|
329
|
+
// In-band command-reply correlation (#275): one FIFO entry per command written to the
|
|
330
|
+
// control client's stdin; tmux answers each with a %begin…%end (or %error) block, in
|
|
331
|
+
// order. The first block on attach is unsolicited (discarded via attachBlockSeen).
|
|
332
|
+
private pendingCommands: PendingCommand[] = [];
|
|
333
|
+
private currentBlock: { num: number; lines: string[] } | null = null;
|
|
334
|
+
private attachBlockSeen = false;
|
|
335
|
+
// Resync runs an async in-band capture; guard against overlapping captures and track how
|
|
336
|
+
// long the ground gate has been deferring (for the CAN fallback).
|
|
337
|
+
private resyncInFlight = false;
|
|
338
|
+
private groundDeferStart = 0;
|
|
199
339
|
|
|
200
340
|
constructor(
|
|
201
341
|
private readonly session: string,
|
|
@@ -209,8 +349,9 @@ class SessionStream {
|
|
|
209
349
|
const socket = tmuxSocketForSession(this.session);
|
|
210
350
|
this.socket = socket;
|
|
211
351
|
// 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
|
-
|
|
352
|
+
// pane (default window-size would shrink it to the new client's 80x24). These are
|
|
353
|
+
// one-time, pre-attach spawnSyncs (the control client isn't up yet) — fine to block.
|
|
354
|
+
const dims = this.paneDimsSync();
|
|
214
355
|
if (dims.cols) this.termCols = dims.cols;
|
|
215
356
|
if (dims.rows) this.termRows = dims.rows;
|
|
216
357
|
Bun.spawnSync(tmuxCommand(socket, "set-window-option", "-t", this.session, "window-size", "manual"), {
|
|
@@ -262,12 +403,45 @@ class SessionStream {
|
|
|
262
403
|
}
|
|
263
404
|
|
|
264
405
|
private handleLine(line: string): void {
|
|
406
|
+
// Inside a reply block, every line is the command's output until its OWN %end/%error
|
|
407
|
+
// (matched by command number — a captured grid row could otherwise masquerade as one).
|
|
408
|
+
// `%output` notifications never appear inside a block, so this can't swallow live deltas.
|
|
409
|
+
if (this.currentBlock) {
|
|
410
|
+
const parsed = parseControlLine(line);
|
|
411
|
+
if ((parsed.type === "end" || parsed.type === "error") && parsed.num === this.currentBlock.num) {
|
|
412
|
+
this.finishBlock(parsed.type === "error");
|
|
413
|
+
} else {
|
|
414
|
+
this.currentBlock.lines.push(line);
|
|
415
|
+
}
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
265
418
|
const parsed = parseControlLine(line);
|
|
266
|
-
if (parsed.type === "
|
|
419
|
+
if (parsed.type === "begin") {
|
|
420
|
+
this.currentBlock = { num: parsed.num, lines: [] };
|
|
421
|
+
} else if (parsed.type === "output") {
|
|
267
422
|
this.enqueue(parsed.bytes);
|
|
268
423
|
} else if (parsed.type === "exit") {
|
|
269
424
|
this.fail(parsed.reason ? `tmux exit: ${parsed.reason}` : "tmux exit");
|
|
270
425
|
}
|
|
426
|
+
// A stray %end/%error with no open block, or any %other notification — ignore.
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Resolve the just-closed reply block against the FIFO head. The very first block on
|
|
430
|
+
// control-mode attach is unsolicited (tmux's empty initial command) — discard it so the
|
|
431
|
+
// correlation never drifts off by one.
|
|
432
|
+
private finishBlock(isError: boolean): void {
|
|
433
|
+
const block = this.currentBlock;
|
|
434
|
+
this.currentBlock = null;
|
|
435
|
+
if (!block) return;
|
|
436
|
+
if (!this.attachBlockSeen) {
|
|
437
|
+
this.attachBlockSeen = true;
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const entry = this.pendingCommands.shift();
|
|
441
|
+
if (!entry) return; // unexpected extra block — drop it rather than mis-correlate
|
|
442
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
443
|
+
if (isError) entry.reject(new Error(block.lines.join(" ").trim() || "tmux command error"));
|
|
444
|
+
else entry.resolve(block.lines.map(latin1LineToUtf8));
|
|
271
445
|
}
|
|
272
446
|
|
|
273
447
|
private enqueue(bytes: Uint8Array): void {
|
|
@@ -297,7 +471,11 @@ class SessionStream {
|
|
|
297
471
|
}
|
|
298
472
|
this.pending = [];
|
|
299
473
|
this.pendingBytes = 0;
|
|
474
|
+
// Track ANSI ground state over LIVE bytes only (injected repaints are balanced by
|
|
475
|
+
// construction). The resync gate and whenAtGround read this to know it's safe to inject.
|
|
476
|
+
this.broadcastState = scanAnsiState(merged, this.broadcastState);
|
|
300
477
|
this.broadcast(merged);
|
|
478
|
+
if (this.broadcastState === "ground") this.fireGroundWaiters();
|
|
301
479
|
// Live deltas just went out; schedule an authoritative resync to correct any drift.
|
|
302
480
|
this.scheduleResync();
|
|
303
481
|
}
|
|
@@ -317,19 +495,106 @@ class SessionStream {
|
|
|
317
495
|
}
|
|
318
496
|
}
|
|
319
497
|
|
|
498
|
+
// Run `cb` at the next ANSI sequence boundary on the live stream (or now, if already at
|
|
499
|
+
// one), so an injected reset/backfill can't splice between the halves of a live escape
|
|
500
|
+
// sequence (#276). Falls back to firing anyway after GROUND_WAIT_MAX_MS if the stream
|
|
501
|
+
// stalls mid-sequence (rare — a dead pane); the reset path does a full clear regardless.
|
|
502
|
+
whenAtGround(cb: () => void): void {
|
|
503
|
+
if (this.closed || this.broadcastState === "ground") {
|
|
504
|
+
cb();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
let fired = false;
|
|
508
|
+
const fire = () => {
|
|
509
|
+
if (fired) return;
|
|
510
|
+
fired = true;
|
|
511
|
+
clearTimeout(timer);
|
|
512
|
+
const i = this.groundWaiters.indexOf(waiter);
|
|
513
|
+
if (i !== -1) this.groundWaiters.splice(i, 1);
|
|
514
|
+
try {
|
|
515
|
+
cb();
|
|
516
|
+
} catch {}
|
|
517
|
+
};
|
|
518
|
+
const waiter = fire;
|
|
519
|
+
const timer = setTimeout(fire, GROUND_WAIT_MAX_MS);
|
|
520
|
+
this.groundWaiters.push(waiter);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private fireGroundWaiters(): void {
|
|
524
|
+
if (this.groundWaiters.length === 0) return;
|
|
525
|
+
const waiters = this.groundWaiters;
|
|
526
|
+
this.groundWaiters = [];
|
|
527
|
+
for (const w of waiters) {
|
|
528
|
+
try {
|
|
529
|
+
w();
|
|
530
|
+
} catch {}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
320
534
|
// Schedule a drift-correcting resync: a trailing debounce fires once output settles,
|
|
321
535
|
// and a capped interval guarantees correction during continuous output.
|
|
322
536
|
private scheduleResync(): void {
|
|
323
537
|
if (RESYNC_DEBOUNCE_MS <= 0 || this.subscribers.size === 0) return;
|
|
324
538
|
this.resyncDirty = true;
|
|
325
539
|
if (this.resyncTimer) clearTimeout(this.resyncTimer);
|
|
326
|
-
this.resyncTimer = setTimeout(() => this.doResync(), RESYNC_DEBOUNCE_MS);
|
|
540
|
+
this.resyncTimer = setTimeout(() => void this.doResync(), RESYNC_DEBOUNCE_MS);
|
|
327
541
|
if (!this.resyncCapTimer && RESYNC_MAX_INTERVAL_MS > 0) {
|
|
328
|
-
this.resyncCapTimer = setTimeout(() => this.doResync(), RESYNC_MAX_INTERVAL_MS);
|
|
542
|
+
this.resyncCapTimer = setTimeout(() => void this.doResync(), RESYNC_MAX_INTERVAL_MS);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private async doResync(): Promise<void> {
|
|
547
|
+
this.clearResyncTimers();
|
|
548
|
+
if (!this.resyncDirty || this.closed || this.subscribers.size === 0) return;
|
|
549
|
+
// One in-band capture at a time; the next flush re-arms us.
|
|
550
|
+
if (this.resyncInFlight) {
|
|
551
|
+
this.resyncTimer = setTimeout(() => void this.doResync(), RESYNC_GROUND_RETRY_MS);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
// Ground-state gate (#276): never splice a repaint between the two halves of a split
|
|
555
|
+
// escape sequence (the injected ESC aborts the half-parsed CSI and its tail prints as
|
|
556
|
+
// literal text — the stray "S"). If we're mid-sequence, keep the work pending and
|
|
557
|
+
// re-check shortly. If the stream stays mid-sequence past the hard cap (stalled pane),
|
|
558
|
+
// force the repaint with a CAN prefix to abort the client's half-sequence.
|
|
559
|
+
let forceAbort = false;
|
|
560
|
+
if (this.broadcastState !== "ground") {
|
|
561
|
+
const now = Date.now();
|
|
562
|
+
if (this.groundDeferStart === 0) this.groundDeferStart = now;
|
|
563
|
+
if (now - this.groundDeferStart < RESYNC_GROUND_DEFER_MAX_MS) {
|
|
564
|
+
this.resyncTimer = setTimeout(() => void this.doResync(), RESYNC_GROUND_RETRY_MS);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
forceAbort = true;
|
|
568
|
+
}
|
|
569
|
+
this.groundDeferStart = 0;
|
|
570
|
+
this.resyncDirty = false;
|
|
571
|
+
this.resyncInFlight = true;
|
|
572
|
+
try {
|
|
573
|
+
const repaint = await this.resyncRepaint();
|
|
574
|
+
if (!repaint || this.closed || this.subscribers.size === 0) return;
|
|
575
|
+
// Ordering (#275): every %output read before the capture's %end is already in
|
|
576
|
+
// `pending`; drain it to subscribers BEFORE the repaint so scrolled lines can't
|
|
577
|
+
// re-apply on top of it (the duplicate-text race). Deltas after %end describe
|
|
578
|
+
// post-capture changes and correctly apply on top of the repaint next flush.
|
|
579
|
+
this.flush();
|
|
580
|
+
const out = forceAbort ? this.prependCan(repaint) : repaint;
|
|
581
|
+
this.broadcast(out);
|
|
582
|
+
tdbg(`resync ${this.session} bytes=${out.length} viewers=${this.subscribers.size}${forceAbort ? " (forced)" : ""}`);
|
|
583
|
+
} catch (e) {
|
|
584
|
+
tdbg(`resync ${this.session} failed: ${errMessage(e)}`);
|
|
585
|
+
} finally {
|
|
586
|
+
this.resyncInFlight = false;
|
|
329
587
|
}
|
|
330
588
|
}
|
|
331
589
|
|
|
332
|
-
private
|
|
590
|
+
private prependCan(bytes: Uint8Array): Uint8Array {
|
|
591
|
+
const out = new Uint8Array(bytes.length + 1);
|
|
592
|
+
out[0] = CAN_BYTE;
|
|
593
|
+
out.set(bytes, 1);
|
|
594
|
+
return out;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private clearResyncTimers(): void {
|
|
333
598
|
if (this.resyncTimer) {
|
|
334
599
|
clearTimeout(this.resyncTimer);
|
|
335
600
|
this.resyncTimer = null;
|
|
@@ -338,66 +603,61 @@ class SessionStream {
|
|
|
338
603
|
clearTimeout(this.resyncCapTimer);
|
|
339
604
|
this.resyncCapTimer = null;
|
|
340
605
|
}
|
|
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
606
|
}
|
|
349
607
|
|
|
350
608
|
// Build an in-place authoritative repaint from tmux's grid: absolute-position each row,
|
|
351
609
|
// reset SGR + clear it, then write tmux's styled cells. This overwrites any drift (a
|
|
352
610
|
// doubled statusline, a solid-instead-of-faded suggestion, stale cells) without a
|
|
353
611
|
// full-screen clear flash, and re-parks the cursor at tmux's true position/visibility.
|
|
354
|
-
|
|
355
|
-
|
|
612
|
+
// The grid + cursor are read in-band through the control client (#275), so the capture
|
|
613
|
+
// is serialized with %output deltas and costs no process spawn.
|
|
614
|
+
private async resyncRepaint(): Promise<Uint8Array | null> {
|
|
615
|
+
const body = await this.readScreen();
|
|
356
616
|
if (!body) return null;
|
|
357
|
-
const cursor = this.readCursorState();
|
|
358
|
-
const
|
|
617
|
+
const cursor = await this.readCursorState();
|
|
618
|
+
const dims = await this.paneDims();
|
|
619
|
+
const rows = dims.rows ?? this.termRows;
|
|
359
620
|
return new TextEncoder().encode(buildInPlaceRepaint(body, rows, cursor));
|
|
360
621
|
}
|
|
361
622
|
|
|
362
|
-
// Read tmux's authoritative
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
private captureScreen(): { content: string; cursorX?: number; cursorY?: number } {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
623
|
+
// Read tmux's authoritative grid (styled) plus a consistent cursor, and turn it into a
|
|
624
|
+
// client repaint. capture-pane reads tmux's real emulator grid, so it's internally
|
|
625
|
+
// coherent; we bracket content/cursor/content (in-band, cheap now) to guard the read
|
|
626
|
+
// against a mid-render change.
|
|
627
|
+
private async captureScreen(): Promise<{ content: string; cursorX?: number; cursorY?: number }> {
|
|
628
|
+
let content = await this.readBackfill();
|
|
629
|
+
let cursor = await this.readCursor();
|
|
630
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
631
|
+
const recheck = await this.readBackfill();
|
|
632
|
+
if (recheck === content) break;
|
|
633
|
+
content = recheck;
|
|
634
|
+
cursor = await this.readCursor();
|
|
635
|
+
}
|
|
371
636
|
return { content: buildScreenRepaint(content, cursor.cursorX, cursor.cursorY), ...cursor };
|
|
372
637
|
}
|
|
373
638
|
|
|
374
639
|
// Backfill capture: current screen plus scrollback history above it (cursor is
|
|
375
640
|
// viewport-relative, so it still parks on the live screen). Falls back to screen-only.
|
|
376
|
-
private readBackfill(): string {
|
|
641
|
+
private async readBackfill(): Promise<string> {
|
|
377
642
|
if (BACKFILL_SCROLLBACK_LINES <= 0) return this.readScreen();
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
);
|
|
382
|
-
return r.exitCode === 0 ? r.stdout.toString("utf8") : this.readScreen();
|
|
643
|
+
const lines = await this.runCommand(
|
|
644
|
+
`capture-pane -p -e -S -${BACKFILL_SCROLLBACK_LINES} -t "${this.session}"`,
|
|
645
|
+
).catch(() => null);
|
|
646
|
+
return lines ? lines.join("\n") : this.readScreen();
|
|
383
647
|
}
|
|
384
648
|
|
|
385
649
|
// Current-screen-only capture (no scrollback) — used by the live resync corrector so it
|
|
386
650
|
// 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);
|
|
651
|
+
private async readScreen(): Promise<string> {
|
|
652
|
+
const lines = await this.runCommand(`capture-pane -p -e -t "${this.session}"`).catch(() => null);
|
|
653
|
+
return lines ? lines.join("\n") : "";
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private async readCursor(): Promise<{ cursorX?: number; cursorY?: number }> {
|
|
657
|
+
const lines = await this.runCommand(
|
|
658
|
+
`display-message -p -t "${this.session}" "#{cursor_x} #{cursor_y}"`,
|
|
659
|
+
).catch(() => [] as string[]);
|
|
660
|
+
const [x, y] = (lines[0] ?? "").trim().split(/\s+/).map(Number);
|
|
401
661
|
return {
|
|
402
662
|
...(Number.isFinite(x) ? { cursorX: x } : {}),
|
|
403
663
|
...(Number.isFinite(y) ? { cursorY: y } : {}),
|
|
@@ -406,12 +666,11 @@ class SessionStream {
|
|
|
406
666
|
|
|
407
667
|
// Cursor position plus visibility (cursor_flag), so a resync repaint restores whether
|
|
408
668
|
// 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+/);
|
|
669
|
+
private async readCursorState(): Promise<{ cursorX?: number; cursorY?: number; visible?: boolean }> {
|
|
670
|
+
const lines = await this.runCommand(
|
|
671
|
+
`display-message -p -t "${this.session}" "#{cursor_x} #{cursor_y} #{cursor_flag}"`,
|
|
672
|
+
).catch(() => [] as string[]);
|
|
673
|
+
const [x, y, flag] = (lines[0] ?? "").trim().split(/\s+/);
|
|
415
674
|
const cx = Number(x);
|
|
416
675
|
const cy = Number(y);
|
|
417
676
|
return {
|
|
@@ -424,25 +683,22 @@ class SessionStream {
|
|
|
424
683
|
// Repaint a freshly-attached (or resumed/refreshed) viewer from tmux's current grid.
|
|
425
684
|
// When the viewer's dimensions are given we resize the tmux pane first so the TUI
|
|
426
685
|
// 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> {
|
|
686
|
+
async backfill(sub: TerminalStreamSubscriber, cols?: number, rows?: number): Promise<TerminalSnapshot> {
|
|
428
687
|
let resized = false;
|
|
429
|
-
if (Number.isFinite(cols) && Number.isFinite(rows) && (cols as number) >= 10 && (rows as number) >= 5) {
|
|
688
|
+
if (this.maySize(sub) && Number.isFinite(cols) && Number.isFinite(rows) && (cols as number) >= 10 && (rows as number) >= 5) {
|
|
430
689
|
const c = Math.trunc(cols as number);
|
|
431
690
|
const r = Math.trunc(rows as number);
|
|
432
691
|
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
|
-
);
|
|
692
|
+
void this.command(`resize-window -t "${this.session}" -x ${c} -y ${r}`);
|
|
437
693
|
this.termCols = c;
|
|
438
694
|
this.termRows = r;
|
|
439
695
|
resized = true;
|
|
440
696
|
}
|
|
441
697
|
}
|
|
442
698
|
if (resized && RESIZE_SETTLE_MS > 0) await Bun.sleep(RESIZE_SETTLE_MS);
|
|
443
|
-
const { content } = this.captureScreen();
|
|
699
|
+
const { content } = await this.captureScreen();
|
|
444
700
|
// Report tmux's actual pane size (authoritative) back to the viewer.
|
|
445
|
-
const dims = this.paneDims();
|
|
701
|
+
const dims = await this.paneDims();
|
|
446
702
|
if (dims.cols) this.termCols = dims.cols;
|
|
447
703
|
if (dims.rows) this.termRows = dims.rows;
|
|
448
704
|
const live = sessionLiveness(this.session);
|
|
@@ -458,45 +714,106 @@ class SessionStream {
|
|
|
458
714
|
};
|
|
459
715
|
}
|
|
460
716
|
|
|
461
|
-
write(bytes: Uint8Array): void {
|
|
717
|
+
write(sub: TerminalStreamSubscriber, bytes: Uint8Array): void {
|
|
462
718
|
if (this.closed || !this.proc || bytes.length === 0) return;
|
|
463
|
-
|
|
719
|
+
// Typing is the strongest interactivity signal — the typist owns window sizing.
|
|
720
|
+
this.sizingOwner = sub;
|
|
721
|
+
void this.command(`send-keys -t "${this.session}" -H ${encodeSendKeysHex(bytes)}`);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
setInteractive(sub: TerminalStreamSubscriber, interactive: boolean): void {
|
|
725
|
+
if (interactive) {
|
|
726
|
+
this.sizingOwner = sub;
|
|
727
|
+
} else if (this.sizingOwner === sub) {
|
|
728
|
+
this.sizingOwner = null;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// A viewer may size the shared window only if it's the sizing owner, or nobody owns
|
|
733
|
+
// sizing yet (first viewer, before anyone has declared interactivity — it still needs
|
|
734
|
+
// a sensible size). Once an interactive viewer exists, read-only watchers never resize.
|
|
735
|
+
private maySize(sub: TerminalStreamSubscriber): boolean {
|
|
736
|
+
return this.sizingOwner === null || this.sizingOwner === sub;
|
|
464
737
|
}
|
|
465
738
|
|
|
466
|
-
resize(cols: number, rows: number): void {
|
|
739
|
+
resize(sub: TerminalStreamSubscriber, cols: number, rows: number): void {
|
|
467
740
|
if (this.closed || !this.proc) return;
|
|
741
|
+
if (!this.maySize(sub)) return; // read-only watcher: don't reflow the shared window
|
|
468
742
|
if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols < 10 || rows < 5) return;
|
|
469
743
|
const c = Math.trunc(cols);
|
|
470
744
|
const r = Math.trunc(rows);
|
|
471
|
-
this.command(`resize-window -t "${this.session}" -x ${c} -y ${r}`);
|
|
745
|
+
void this.command(`resize-window -t "${this.session}" -x ${c} -y ${r}`);
|
|
472
746
|
this.termCols = c;
|
|
473
747
|
this.termRows = r;
|
|
474
748
|
tdbg(`resize ${this.session} -> ${c}x${r} viewers=${this.subscribers.size}`);
|
|
475
749
|
}
|
|
476
750
|
|
|
477
|
-
|
|
751
|
+
// Pane dimensions, in-band through the control client (no spawn).
|
|
752
|
+
private async paneDims(): Promise<{ cols?: number; rows?: number }> {
|
|
753
|
+
const lines = await this.runCommand(
|
|
754
|
+
`display-message -p -t "${this.session}" "#{pane_width} #{pane_height}"`,
|
|
755
|
+
).catch(() => [] as string[]);
|
|
756
|
+
return parsePaneDims(lines[0] ?? "");
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Synchronous pane-dims read for start(), which runs before the control client attaches.
|
|
760
|
+
private paneDimsSync(): { cols?: number; rows?: number } {
|
|
478
761
|
try {
|
|
479
762
|
const out = Bun.spawnSync(
|
|
480
763
|
tmuxCommand(this.socket, "display-message", "-p", "-t", this.session, "#{pane_width} #{pane_height}"),
|
|
481
764
|
{ stdin: "ignore", stdout: "pipe", stderr: "ignore" },
|
|
482
765
|
).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
|
-
};
|
|
766
|
+
return parsePaneDims(out);
|
|
488
767
|
} catch {
|
|
489
768
|
return {};
|
|
490
769
|
}
|
|
491
770
|
}
|
|
492
771
|
|
|
493
|
-
|
|
772
|
+
// Run a control command expecting its reply lines (the FIFO correlates them in order).
|
|
773
|
+
private runCommand(line: string): Promise<string[]> {
|
|
774
|
+
return this.command(line, true);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Write a command to the control client's stdin and register a FIFO entry for its reply
|
|
778
|
+
// block. `wantReply` commands resolve with the block's lines (or reject on %error /
|
|
779
|
+
// timeout); fire-and-forget commands resolve with [] once their empty block closes.
|
|
780
|
+
private command(line: string, wantReply = false): Promise<string[]> {
|
|
781
|
+
if (this.closed) return wantReply ? Promise.reject(new Error("terminal stream closed")) : Promise.resolve([]);
|
|
494
782
|
const stdin = this.proc?.stdin;
|
|
495
|
-
if (!stdin || typeof stdin === "number")
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
783
|
+
if (!stdin || typeof stdin === "number") {
|
|
784
|
+
return wantReply ? Promise.reject(new Error("control client unavailable")) : Promise.resolve([]);
|
|
785
|
+
}
|
|
786
|
+
return new Promise<string[]>((resolve, reject) => {
|
|
787
|
+
const entry: PendingCommand = { wantReply, lines: [], resolve, reject, timer: null };
|
|
788
|
+
if (wantReply) entry.timer = setTimeout(() => this.commandTimeout(entry), COMMAND_TIMEOUT_MS);
|
|
789
|
+
this.pendingCommands.push(entry);
|
|
790
|
+
try {
|
|
791
|
+
(stdin as { write(data: string): void; flush?(): void }).write(`${line}\n`);
|
|
792
|
+
(stdin as { flush?(): void }).flush?.();
|
|
793
|
+
} catch (e) {
|
|
794
|
+
const idx = this.pendingCommands.indexOf(entry);
|
|
795
|
+
if (idx !== -1) this.pendingCommands.splice(idx, 1);
|
|
796
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
797
|
+
if (wantReply) reject(e instanceof Error ? e : new Error(String(e)));
|
|
798
|
+
else resolve([]);
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// A reply that never arrived means a protocol desync — reject the awaited command and
|
|
804
|
+
// reset the whole reply queue + block state so the next command starts clean. The byte
|
|
805
|
+
// stream itself keeps flowing; only in-flight captures fail (the resync just skips a beat).
|
|
806
|
+
private commandTimeout(entry: PendingCommand): void {
|
|
807
|
+
if (!this.pendingCommands.includes(entry)) return;
|
|
808
|
+
tdbg(`command timeout ${this.session}; resetting reply queue (${this.pendingCommands.length} pending)`);
|
|
809
|
+
const pend = this.pendingCommands;
|
|
810
|
+
this.pendingCommands = [];
|
|
811
|
+
this.currentBlock = null;
|
|
812
|
+
for (const e of pend) {
|
|
813
|
+
if (e.timer) clearTimeout(e.timer);
|
|
814
|
+
if (e.wantReply) e.reject(new Error("control command timed out"));
|
|
815
|
+
else e.resolve([]);
|
|
816
|
+
}
|
|
500
817
|
}
|
|
501
818
|
|
|
502
819
|
addSubscriber(sub: TerminalStreamSubscriber): void {
|
|
@@ -506,6 +823,7 @@ class SessionStream {
|
|
|
506
823
|
|
|
507
824
|
removeSubscriber(sub: TerminalStreamSubscriber): void {
|
|
508
825
|
if (!this.subscribers.delete(sub)) return;
|
|
826
|
+
if (this.sizingOwner === sub) this.sizingOwner = null;
|
|
509
827
|
tdbg(`detach ${this.session} viewers=${this.subscribers.size}`);
|
|
510
828
|
if (this.subscribers.size === 0) this.destroy();
|
|
511
829
|
}
|
|
@@ -529,14 +847,17 @@ class SessionStream {
|
|
|
529
847
|
clearTimeout(this.flushTimer);
|
|
530
848
|
this.flushTimer = null;
|
|
531
849
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
850
|
+
this.clearResyncTimers();
|
|
851
|
+
// Reject any in-flight in-band commands so awaiters (captures) unwind instead of hanging.
|
|
852
|
+
const pend = this.pendingCommands;
|
|
853
|
+
this.pendingCommands = [];
|
|
854
|
+
this.currentBlock = null;
|
|
855
|
+
for (const e of pend) {
|
|
856
|
+
if (e.timer) clearTimeout(e.timer);
|
|
857
|
+
if (e.wantReply) e.reject(new Error("terminal stream closed"));
|
|
858
|
+
else e.resolve([]);
|
|
539
859
|
}
|
|
860
|
+
this.fireGroundWaiters();
|
|
540
861
|
try {
|
|
541
862
|
this.proc?.kill();
|
|
542
863
|
} catch {}
|
|
@@ -563,9 +884,11 @@ export function acquireTerminalStream(
|
|
|
563
884
|
const active = stream;
|
|
564
885
|
active.addSubscriber(subscriber);
|
|
565
886
|
return {
|
|
566
|
-
backfill: (cols, rows) => active.backfill(cols, rows),
|
|
567
|
-
write: (bytes) => active.write(bytes),
|
|
568
|
-
resize: (cols, rows) => active.resize(cols, rows),
|
|
887
|
+
backfill: (cols, rows) => active.backfill(subscriber, cols, rows),
|
|
888
|
+
write: (bytes) => active.write(subscriber, bytes),
|
|
889
|
+
resize: (cols, rows) => active.resize(subscriber, cols, rows),
|
|
890
|
+
setInteractive: (interactive) => active.setInteractive(subscriber, interactive),
|
|
891
|
+
whenAtGround: (cb) => active.whenAtGround(cb),
|
|
569
892
|
release: () => active.removeSubscriber(subscriber),
|
|
570
893
|
};
|
|
571
894
|
}
|
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
|
/**
|
|
@@ -1055,7 +1086,9 @@ function workspaceId(input: WorkspaceResolutionInput): string {
|
|
|
1055
1086
|
|
|
1056
1087
|
function branchName(input: WorkspaceResolutionInput, id: string): string {
|
|
1057
1088
|
const owner = input.policyName || input.label || input.automationId || "manual";
|
|
1058
|
-
|
|
1089
|
+
// 40 fits a full UUID (36) plus the `sp_`-stripped slack. A tighter cap (was 24)
|
|
1090
|
+
// sliced the session UUID mid-string, leaving an unaddressable, dangling ref (#282).
|
|
1091
|
+
return `agent/${safeSegment(owner, 48)}/${safeSegment(id.replace(/^sp[_-]?/, ""), 40)}`;
|
|
1059
1092
|
}
|
|
1060
1093
|
|
|
1061
1094
|
/** Next free `<branch>-N` cycle name for a recycled worktree (#206). Strips any
|
|
@@ -1069,6 +1102,10 @@ function nextBranchName(repoRoot: string, branch: string): string {
|
|
|
1069
1102
|
return `${stem}-${Date.now()}`;
|
|
1070
1103
|
}
|
|
1071
1104
|
|
|
1105
|
+
export function workspacesRoot(baseDir: string): string {
|
|
1106
|
+
return join(resolve(baseDir), ".agent-relay", "workspaces");
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1072
1109
|
function repoSlug(repoRoot: string): string {
|
|
1073
1110
|
const hash = createHash("sha1").update(resolve(repoRoot)).digest("hex").slice(0, 10);
|
|
1074
1111
|
return `${safeSegment(basename(repoRoot), 60)}-${hash}`;
|