Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

## Unreleased

### Harness-neutral live activity status

- Add a single generation-bound activity lease with ordered `unknown`,
`active`, `child_command`, and `idle` transitions. Live status exposes the
accepted state and resets it to `unknown` when the publisher disconnects or
the daemon generation changes; stale epochs, skipped updates, malformed
commands, and competing publishers are rejected.
- Export `connectActivityPublisher()` for harness-specific adapters while
keeping hook and log interpretation outside pty. `pty stats` and
`queryStats()` now also expose `modes.alternateScreen` as a diagnostic only;
terminal modes do not imply semantic activity or idleness.

### Storage format

- Supporting live daemons now advertise a `recovery` capability in session
Expand Down Expand Up @@ -121,6 +133,7 @@ notification because pty does not journal a cross-file transaction.
semantics. An attached client that switches to readonly via `PEEK` now
relinquishes its requested geometry, re-negotiating the effective size when
necessary.

### Read-only session listing

- `listSessions()` and `pty list` are now strictly observational: they no
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ Like `git`, `pty` supports extensions: if you run `pty foo` and there's a `pty-f
import {
spawnDaemon, listSessions, getSession,
SessionConnection, sendData, peekScreen, queryStats,
connectActivityPublisher,
EventFollower, readRecentEvents,
extractFilterTags, matchesAllTags,
} from "@compoundingtech/pty/client";
Expand Down Expand Up @@ -447,6 +448,13 @@ const sessions = await listSessions();
const stats = await queryStats("myserver");
```

`queryStats()` includes generation-bound live activity
(`unknown`, `active`, `child_command`, or `idle`) and terminal diagnostics such
as `modes.alternateScreen`. Activity is explicit publisher state; terminal
modes never imply idleness. Harness adapters can hold the single live lease
with `connectActivityPublisher()` and publish ordered transitions. The state
resets to `unknown` when that connection or daemon generation ends.

### Connecting to a session

`SessionConnection` provides a bidirectional, event-driven connection without taking over stdin/stdout — ideal for GUI apps, multiplexers, or web interfaces:
Expand Down
40 changes: 40 additions & 0 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,9 +336,18 @@ interface StatsResult {
>;
};
modes: {
alternateScreen: boolean;
sgrMouse: boolean; cursorHidden: boolean;
kittyKeyboard: boolean; kittyKeyboardFlags: number[];
};
activity: {
state: "unknown" | "active" | "child_command" | "idle";
generation: string;
producerEpoch: string | null;
sequence: number;
turnId?: string;
source?: string;
};
uptimeSeconds: number | null;
createdAt: string | null;
}
Expand All @@ -349,6 +358,36 @@ interface ProcessResources {
}
```

`activity` is an explicitly published, generation-bound live fact. It starts
as `unknown` and returns to `unknown` when the publisher disconnects or the
daemon generation changes. Terminal modes such as `alternateScreen` are
diagnostics only and do not imply activity or idleness.

### `connectActivityPublisher(name, options?): Promise<ActivityPublisher>`

Claim the session's single live activity lease and publish harness-neutral
state transitions. Harness-specific hook or log parsing belongs in the caller.

```typescript
import { connectActivityPublisher } from "@compoundingtech/pty/client";

const activity = await connectActivityPublisher("myserver", {
source: "codex",
});

await activity.publish("active", { turnId: "turn-42" });
await activity.publish("child_command", { turnId: "turn-42" });
await activity.publish("idle", { turnId: "turn-42" });

activity.close(); // STATUS immediately falls back to unknown
```

Updates are strictly sequenced within a random producer epoch. A competing
publisher is rejected while the lease is held. Publisher failure, socket loss,
malformed updates, skipped sequences, stale epochs, and daemon replacement all
fail closed to `unknown`. Rejected competing sockets do not disturb the current
publisher.

Connection details are anonymous and their order is unspecified. They are a
point-in-time explanation of the current min-wins result, not an event stream;
polling stats cannot order geometry changes relative to attached-session DATA.
Expand Down Expand Up @@ -497,6 +536,7 @@ const MessageType = {
SCREEN: 5, // Screen replay
PEEK: 6, // Read-only peek request
STATUS: 7, // Stats query/response
ACTIVITY: 8, // Generic activity lease commands/responses
GEOMETRY: 10, // Effective shared rows/cols (server → client)
};
```
Expand Down
152 changes: 152 additions & 0 deletions src/activity-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import * as net from "node:net";
import { randomUUID } from "node:crypto";
import {
type ActivityClaim,
type ActivityResponse,
type ActivityState,
type ActivityStatus,
type ActivityUpdate,
} from "./activity.ts";
import { MessageType, PacketReader, encodeActivity } from "./protocol.ts";
import { getSocketPath } from "./sessions.ts";

export interface ActivityPublisherOptions {
producerEpoch?: string;
source?: string;
timeoutMs?: number;
}

export interface ActivityPublishOptions {
turnId?: string;
}

export class ActivityPublisher {
private readonly reader = new PacketReader();
private pending: {
resolve: (value: ActivityStatus) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
} | null = null;
private sequence = 0;
private closed = false;

private constructor(
private readonly socket: net.Socket,
readonly producerEpoch: string,
private readonly timeoutMs: number,
) {
socket.on("data", (data) => {
let packets;
try {
packets = this.reader.feed(Buffer.isBuffer(data) ? data : Buffer.from(data));
} catch (error) {
this.failPending(error instanceof Error ? error : new Error(String(error)));
socket.destroy();
return;
}
for (const packet of packets) {
if (packet.type !== MessageType.ACTIVITY || this.pending === null) continue;
let response: ActivityResponse;
try {
response = JSON.parse(packet.payload.toString("utf8")) as ActivityResponse;
} catch {
this.failPending(new Error("invalid activity response"));
socket.destroy();
return;
}
const pending = this.pending;
this.pending = null;
clearTimeout(pending.timer);
if (!response.ok) {
pending.reject(new Error(response.error ?? "activity update rejected"));
} else {
this.sequence = response.activity.sequence;
pending.resolve(response.activity);
}
}
});
socket.on("error", (error) => this.failPending(error));
socket.on("close", () => {
this.closed = true;
this.failPending(new Error("activity publisher connection closed"));
});
}

static async connect(
name: string,
options: ActivityPublisherOptions = {},
): Promise<ActivityPublisher> {
const socket = net.createConnection(getSocketPath(name));
await new Promise<void>((resolve, reject) => {
socket.once("connect", resolve);
socket.once("error", reject);
});
const publisher = new ActivityPublisher(
socket,
options.producerEpoch ?? randomUUID(),
options.timeoutMs ?? 2000,
);
const claim: ActivityClaim = {
op: "claim",
producerEpoch: publisher.producerEpoch,
...(options.source === undefined ? {} : { source: options.source }),
};
try {
await publisher.request(claim);
return publisher;
} catch (error) {
publisher.close();
throw error;
}
}

publish(
state: ActivityState,
options: ActivityPublishOptions = {},
): Promise<ActivityStatus> {
const update: ActivityUpdate = {
op: "set",
producerEpoch: this.producerEpoch,
sequence: this.sequence + 1,
state,
...(options.turnId === undefined ? {} : { turnId: options.turnId }),
};
return this.request(update);
}

close(): void {
this.closed = true;
this.socket.destroy();
}

private request(command: ActivityClaim | ActivityUpdate): Promise<ActivityStatus> {
if (this.closed) return Promise.reject(new Error("activity publisher is closed"));
if (this.pending !== null) {
return Promise.reject(new Error("activity update already in flight"));
}
return new Promise<ActivityStatus>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending = null;
reject(new Error("timed out waiting for activity response"));
this.socket.destroy();
}, this.timeoutMs);
this.pending = { resolve, reject, timer };
this.socket.write(encodeActivity(command));
});
}

private failPending(error: Error): void {
if (this.pending === null) return;
const pending = this.pending;
this.pending = null;
clearTimeout(pending.timer);
pending.reject(error);
}
}

export function connectActivityPublisher(
name: string,
options: ActivityPublisherOptions = {},
): Promise<ActivityPublisher> {
return ActivityPublisher.connect(name, options);
}
Loading
Loading