Skip to content

Commit 64edbe2

Browse files
feat(stats): expose connected client geometry
1 parent c23812e commit 64edbe2

7 files changed

Lines changed: 148 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
## Unreleased
44

5+
### Connected-client geometry introspection
6+
7+
- `pty stats --json` and `queryStats()` retain their existing effective
8+
terminal geometry and aggregate client counts, and now add anonymous
9+
`clients.connections` details. Writable entries report their requested
10+
rows/columns, last request sequence, and which min-wins axes they currently
11+
constrain; readonly entries carry no geometry. This is point-in-time
12+
observability only and does not change attach, resize, negotiation, or DATA
13+
ordering semantics.
14+
515
### Read-only session listing
616

717
- `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: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,11 @@ const plain = await peekScreen({ name: "myserver", plain: true }); // plain text
233233

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

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

238242
```typescript
239243
interface StatsResult {
@@ -252,7 +256,21 @@ interface StatsResult {
252256
pid: number;
253257
resources: ProcessResources | null;
254258
};
255-
clients: { total: number; attached: number; readOnly: number };
259+
clients: {
260+
total: number; attached: number; readOnly: number;
261+
connections: Array<
262+
| {
263+
role: "writable";
264+
rows: number; cols: number;
265+
lastRequestSequence: number;
266+
constrains: { rows: boolean; cols: boolean };
267+
}
268+
| {
269+
role: "readonly";
270+
constrains: { rows: false; cols: false };
271+
}
272+
>;
273+
};
256274
modes: {
257275
sgrMouse: boolean; cursorHidden: boolean;
258276
kittyKeyboard: boolean; kittyKeyboardFlags: number[];
@@ -267,6 +285,14 @@ interface ProcessResources {
267285
}
268286
```
269287

288+
Connection details are anonymous and their order is unspecified. They are a
289+
point-in-time explanation of the current min-wins result, not an event stream;
290+
polling stats cannot order geometry changes relative to attached-session DATA.
291+
`lastRequestSequence` is a daemon-local counter for the writable connection's
292+
most recent attach or resize request, not a connection identity or timestamp.
293+
The daemon does not retain a durable client identity; socket and packet-parser
294+
state are transport internals and are not exposed.
295+
270296
## Session Interaction (CLI-oriented)
271297

272298
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: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
type SessionMetadata,
3333
} from "./sessions.ts";
3434
import { EventWriter, clearEvents, EventType, type EventRecord } from "./events.ts";
35+
import type { StatsResult } from "./client.ts";
3536

3637
interface Client {
3738
socket: net.Socket;
@@ -767,15 +768,33 @@ export class PtyServer {
767768
return prefix;
768769
}
769770

770-
private collectStats(): object {
771+
private collectStats(): StatsResult {
771772
const buf = this.terminal.buffer.active;
772773
const meta = readMetadata(this.name);
773774

774775
let attached = 0;
775776
let readOnly = 0;
777+
const connections: StatsResult["clients"]["connections"] = [];
776778
for (const c of this.clients.values()) {
777-
if (c.readonly) readOnly++;
778-
else if (c.attachSeq > 0) attached++;
779+
if (c.readonly) {
780+
readOnly++;
781+
connections.push({
782+
role: "readonly",
783+
constrains: { rows: false, cols: false },
784+
});
785+
} else if (c.attachSeq > 0) {
786+
attached++;
787+
connections.push({
788+
role: "writable",
789+
rows: c.rows,
790+
cols: c.cols,
791+
lastRequestSequence: c.attachSeq,
792+
constrains: {
793+
rows: c.rows === this.terminal.rows,
794+
cols: c.cols === this.terminal.cols,
795+
},
796+
});
797+
}
779798
}
780799

781800
const createdAt = meta?.createdAt ?? null;
@@ -810,6 +829,7 @@ export class PtyServer {
810829
total: attached + readOnly,
811830
attached,
812831
readOnly,
832+
connections,
813833
},
814834
modes: {
815835
sgrMouse: this.sgrMouseMode,

tests/integration.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1384,6 +1384,61 @@ 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+
13871442
it("reports exited process", async () => {
13881443
const name = uniqueName();
13891444
await startServer(name, "sh", ["-c", "exit 7"]);

tests/protocol.test.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -258,15 +258,28 @@ describe("protocol", () => {
258258

259259
it("round-trips a STATUS response (JSON payload)", () => {
260260
const reader = new PacketReader();
261-
const json = JSON.stringify({ name: "test", terminal: { cols: 80, rows: 24 } });
261+
const response = {
262+
name: "test",
263+
terminal: { cols: 80, rows: 24 },
264+
clients: {
265+
total: 1,
266+
attached: 1,
267+
readOnly: 0,
268+
connections: [{
269+
role: "writable",
270+
rows: 24,
271+
cols: 80,
272+
lastRequestSequence: 1,
273+
constrains: { rows: true, cols: true },
274+
}],
275+
},
276+
};
277+
const json = JSON.stringify(response);
262278
const encoded = encodeStatusResponse(json);
263279
const packets = reader.feed(encoded);
264280
expect(packets).toHaveLength(1);
265281
expect(packets[0].type).toBe(MessageType.STATUS);
266-
expect(JSON.parse(packets[0].payload.toString())).toEqual({
267-
name: "test",
268-
terminal: { cols: 80, rows: 24 },
269-
});
282+
expect(JSON.parse(packets[0].payload.toString())).toEqual(response);
270283
});
271284
});
272285
});

0 commit comments

Comments
 (0)