Skip to content

Commit cc7a59b

Browse files
feat(stats): expose connected client geometry (#136)
* feat(stats): expose connected client geometry * fix(stats): support legacy daemon responses * fix(server): renegotiate when attached client peeks * docs: describe PEEK size renegotiation --------- Co-authored-by: Nathan Herald <me@nathanherald.com>
1 parent 8046ba9 commit cc7a59b

7 files changed

Lines changed: 235 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@
1515
clients safely ignore the bounded unknown packet and retain raw-byte
1616
behavior.
1717

18+
### Connected-client geometry introspection
19+
20+
- `pty stats --json` and `queryStats()` retain their existing effective
21+
terminal geometry and aggregate client counts, and now add anonymous
22+
`clients.connections` details. Writable entries report their requested
23+
rows/columns, last request sequence, and which min-wins axes they currently
24+
constrain; readonly entries carry no geometry. This is point-in-time
25+
observability and does not change attach/resize min-wins or DATA ordering
26+
semantics. An attached client that switches to readonly via `PEEK` now
27+
relinquishes its requested geometry, re-negotiating the effective size when
28+
necessary.
29+
1830
### Read-only session listing
1931

2032
- `listSessions()` and `pty list` are now strictly observational: they no

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ pty send myserver --paste "$(cat prompt.md)" # wrap as bracketed paste
8989

9090
pty stats # live metrics for all sessions
9191
pty stats myserver # stats for a specific session
92-
pty stats --json # stats as JSON (includes CPU, memory, PIDs)
92+
pty stats --json # effective geometry, anonymous clients, CPU/memory/PIDs
9393

9494
pty events myserver # follow events in real-time
9595
pty events --all # follow events from all sessions

docs/client.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,11 @@ const plain = await peekScreen({ name: "myserver", plain: true }); // plain text
246246

247247
### `queryStats(name: string, timeoutMs?: number): Promise<StatsResult>`
248248

249-
Query live metrics from a running session.
249+
Query live metrics from a running session without attaching. The matching
250+
`pty stats --json` command uses the same non-attaching STATUS request.
251+
`terminal.rows` and `terminal.cols` are the current effective shared geometry;
252+
`clients` includes aggregate counts plus anonymous connection details showing
253+
each writable client's requested size and which min-wins axes it constrains.
250254

251255
```typescript
252256
interface StatsResult {
@@ -265,7 +269,21 @@ interface StatsResult {
265269
pid: number;
266270
resources: ProcessResources | null;
267271
};
268-
clients: { total: number; attached: number; readOnly: number };
272+
clients: {
273+
total: number; attached: number; readOnly: number;
274+
connections?: Array<
275+
| {
276+
role: "writable";
277+
rows: number; cols: number;
278+
lastRequestSequence: number;
279+
constrains: { rows: boolean; cols: boolean };
280+
}
281+
| {
282+
role: "readonly";
283+
constrains: { rows: false; cols: false };
284+
}
285+
>;
286+
};
269287
modes: {
270288
sgrMouse: boolean; cursorHidden: boolean;
271289
kittyKeyboard: boolean; kittyKeyboardFlags: number[];
@@ -280,6 +298,16 @@ interface ProcessResources {
280298
}
281299
```
282300

301+
Connection details are anonymous and their order is unspecified. They are a
302+
point-in-time explanation of the current min-wins result, not an event stream;
303+
polling stats cannot order geometry changes relative to attached-session DATA.
304+
`lastRequestSequence` is a daemon-local counter for the writable connection's
305+
most recent attach or resize request, not a connection identity or timestamp.
306+
Older daemons omit `connections`; the aggregate counts remain authoritative and
307+
must not be reconstructed as an empty connection list. The daemon does not
308+
retain a durable client identity; socket and packet-parser state are transport
309+
internals and are not exposed.
310+
283311
## Session Interaction (CLI-oriented)
284312

285313
These functions use `process.stdin`/`process.stdout` directly and may call `process.exit()`. They are re-exported for tools that want CLI-like behavior.

src/client.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,19 @@ export interface StatsResult {
314314
total: number;
315315
attached: number;
316316
readOnly: number;
317+
connections?: Array<
318+
| {
319+
role: "writable";
320+
rows: number;
321+
cols: number;
322+
lastRequestSequence: number;
323+
constrains: { rows: boolean; cols: boolean };
324+
}
325+
| {
326+
role: "readonly";
327+
constrains: { rows: false; cols: false };
328+
}
329+
>;
317330
};
318331
modes: {
319332
sgrMouse: boolean;

src/server.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
type SessionMetadata,
3434
} from "./sessions.ts";
3535
import { EventWriter, clearEvents, EventType, type EventRecord } from "./events.ts";
36+
import type { StatsResult } from "./client.ts";
3637

3738
interface Client {
3839
socket: net.Socket;
@@ -690,7 +691,10 @@ export class PtyServer {
690691

691692
case MessageType.PEEK: {
692693
client.readonly = true;
693-
socket.write(encodeGeometry(this.terminal.rows, this.terminal.cols));
694+
const resized = this.negotiateSize();
695+
if (!resized) {
696+
socket.write(encodeGeometry(this.terminal.rows, this.terminal.cols));
697+
}
694698
const flags = packet.payload.length > 0 ? packet.payload.readUInt8(0) : 0;
695699
const plain = (flags & 1) !== 0;
696700
const full = (flags & 2) !== 0;
@@ -772,15 +776,33 @@ export class PtyServer {
772776
return prefix;
773777
}
774778

775-
private collectStats(): object {
779+
private collectStats(): StatsResult {
776780
const buf = this.terminal.buffer.active;
777781
const meta = readMetadata(this.name);
778782

779783
let attached = 0;
780784
let readOnly = 0;
785+
const connections: NonNullable<StatsResult["clients"]["connections"]> = [];
781786
for (const c of this.clients.values()) {
782-
if (c.readonly) readOnly++;
783-
else if (c.attachSeq > 0) attached++;
787+
if (c.readonly) {
788+
readOnly++;
789+
connections.push({
790+
role: "readonly",
791+
constrains: { rows: false, cols: false },
792+
});
793+
} else if (c.attachSeq > 0) {
794+
attached++;
795+
connections.push({
796+
role: "writable",
797+
rows: c.rows,
798+
cols: c.cols,
799+
lastRequestSequence: c.attachSeq,
800+
constrains: {
801+
rows: c.rows === this.terminal.rows,
802+
cols: c.cols === this.terminal.cols,
803+
},
804+
});
805+
}
784806
}
785807

786808
const createdAt = meta?.createdAt ?? null;
@@ -815,6 +837,7 @@ export class PtyServer {
815837
total: attached + readOnly,
816838
attached,
817839
readOnly,
840+
connections,
818841
},
819842
modes: {
820843
sgrMouse: this.sgrMouseMode,

tests/integration.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1384,6 +1384,107 @@ describe("STATUS message", () => {
13841384
statsClient.destroy();
13851385
});
13861386

1387+
it("reports anonymous client geometry and per-axis constraints", async () => {
1388+
const name = uniqueName();
1389+
await startServer(name, "cat");
1390+
1391+
const tall = await connect(name);
1392+
const tallReader = new PacketReader();
1393+
tall.write(encodeAttach(50, 80));
1394+
await waitForType(tall, tallReader, MessageType.SCREEN);
1395+
1396+
const wide = await connect(name);
1397+
const wideReader = new PacketReader();
1398+
wide.write(encodeAttach(30, 120));
1399+
await waitForType(wide, wideReader, MessageType.SCREEN);
1400+
1401+
const peeker = await connect(name);
1402+
const peekReader = new PacketReader();
1403+
peeker.write(encodePeek());
1404+
await waitForType(peeker, peekReader, MessageType.SCREEN);
1405+
1406+
const statsClient = await connect(name);
1407+
const statsReader = new PacketReader();
1408+
statsClient.write(encodeStatus());
1409+
1410+
const packet = await waitForType(statsClient, statsReader, MessageType.STATUS);
1411+
const stats = JSON.parse(packet.payload.toString());
1412+
1413+
expect(stats.terminal).toMatchObject({ rows: 30, cols: 80 });
1414+
expect(stats.clients.connections).toHaveLength(3);
1415+
expect(stats.clients.connections).toEqual(expect.arrayContaining([
1416+
{
1417+
role: "writable",
1418+
rows: 50,
1419+
cols: 80,
1420+
lastRequestSequence: 1,
1421+
constrains: { rows: false, cols: true },
1422+
},
1423+
{
1424+
role: "writable",
1425+
rows: 30,
1426+
cols: 120,
1427+
lastRequestSequence: 2,
1428+
constrains: { rows: true, cols: false },
1429+
},
1430+
{
1431+
role: "readonly",
1432+
constrains: { rows: false, cols: false },
1433+
},
1434+
]));
1435+
1436+
tall.destroy();
1437+
wide.destroy();
1438+
peeker.destroy();
1439+
statsClient.destroy();
1440+
});
1441+
1442+
it("relinquishes a writable client's geometry constraints when it peeks", async () => {
1443+
const name = uniqueName();
1444+
await startServer(name, "cat", [], { rows: 50, cols: 120 });
1445+
1446+
const smaller = await connect(name);
1447+
const smallerReader = new PacketReader();
1448+
smaller.write(encodeAttach(30, 80));
1449+
await waitForType(smaller, smallerReader, MessageType.SCREEN);
1450+
1451+
const larger = await connect(name);
1452+
const largerReader = new PacketReader();
1453+
larger.write(encodeAttach(50, 120));
1454+
await waitForType(larger, largerReader, MessageType.SCREEN);
1455+
1456+
smaller.write(encodePeek());
1457+
await waitForType(smaller, smallerReader, MessageType.SCREEN);
1458+
const geometry = await waitForType(larger, largerReader, MessageType.GEOMETRY);
1459+
expect(geometry.payload.readUInt16BE(0)).toBe(50);
1460+
expect(geometry.payload.readUInt16BE(2)).toBe(120);
1461+
1462+
const statsClient = await connect(name);
1463+
const statsReader = new PacketReader();
1464+
statsClient.write(encodeStatus());
1465+
const packet = await waitForType(statsClient, statsReader, MessageType.STATUS);
1466+
const stats = JSON.parse(packet.payload.toString());
1467+
1468+
expect(stats.terminal).toMatchObject({ rows: 50, cols: 120 });
1469+
expect(stats.clients.connections).toEqual(expect.arrayContaining([
1470+
{
1471+
role: "readonly",
1472+
constrains: { rows: false, cols: false },
1473+
},
1474+
{
1475+
role: "writable",
1476+
rows: 50,
1477+
cols: 120,
1478+
lastRequestSequence: 2,
1479+
constrains: { rows: true, cols: true },
1480+
},
1481+
]));
1482+
1483+
smaller.destroy();
1484+
larger.destroy();
1485+
statsClient.destroy();
1486+
});
1487+
13871488
it("reports exited process", async () => {
13881489
const name = uniqueName();
13891490
await startServer(name, "sh", ["-c", "exit 7"]);

tests/protocol.test.ts

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
decodeExit,
2020
} from "../src/protocol.ts";
2121
import { Buffer } from "node:buffer";
22+
import type { StatsResult } from "../src/client.ts";
2223

2324
describe("protocol", () => {
2425
describe("encodePacket / PacketReader", () => {
@@ -260,15 +261,28 @@ describe("protocol", () => {
260261

261262
it("round-trips a STATUS response (JSON payload)", () => {
262263
const reader = new PacketReader();
263-
const json = JSON.stringify({ name: "test", terminal: { cols: 80, rows: 24 } });
264+
const response = {
265+
name: "test",
266+
terminal: { cols: 80, rows: 24 },
267+
clients: {
268+
total: 1,
269+
attached: 1,
270+
readOnly: 0,
271+
connections: [{
272+
role: "writable",
273+
rows: 24,
274+
cols: 80,
275+
lastRequestSequence: 1,
276+
constrains: { rows: true, cols: true },
277+
}],
278+
},
279+
};
280+
const json = JSON.stringify(response);
264281
const encoded = encodeStatusResponse(json);
265282
const packets = reader.feed(encoded);
266283
expect(packets).toHaveLength(1);
267284
expect(packets[0].type).toBe(MessageType.STATUS);
268-
expect(JSON.parse(packets[0].payload.toString())).toEqual({
269-
name: "test",
270-
terminal: { cols: 80, rows: 24 },
271-
});
285+
expect(JSON.parse(packets[0].payload.toString())).toEqual(response);
272286
});
273287

274288
it("round-trips an effective GEOMETRY packet", () => {
@@ -297,5 +311,37 @@ describe("protocol", () => {
297311
}
298312
expect(received).toBe("after-unknown");
299313
});
314+
315+
it("accepts an old-daemon STATUS response without connection details", () => {
316+
const response = {
317+
name: "legacy",
318+
terminal: {
319+
cols: 80,
320+
rows: 24,
321+
cursorX: 0,
322+
cursorY: 0,
323+
scrollbackUsed: 24,
324+
scrollbackCapacity: 10024,
325+
},
326+
process: { alive: true, exitCode: null, pid: 123, resources: null },
327+
daemon: { pid: 456, resources: null },
328+
clients: { total: 2, attached: 2, readOnly: 0 },
329+
modes: {
330+
sgrMouse: false,
331+
cursorHidden: false,
332+
kittyKeyboard: false,
333+
kittyKeyboardFlags: [],
334+
},
335+
uptimeSeconds: 10,
336+
createdAt: "2026-07-31T00:00:00.000Z",
337+
} satisfies StatsResult;
338+
339+
const reader = new PacketReader();
340+
const packets = reader.feed(encodeStatusResponse(JSON.stringify(response)));
341+
const decoded = JSON.parse(packets[0].payload.toString()) as StatsResult;
342+
343+
expect(decoded.clients).toEqual({ total: 2, attached: 2, readOnly: 0 });
344+
expect(decoded.clients.connections).toBeUndefined();
345+
});
300346
});
301347
});

0 commit comments

Comments
 (0)