Skip to content

Commit 1abc157

Browse files
author
JosXa
committed
feat: add pty_snapshot_wait tool and seq-based diff history
- pty_snapshot_wait: blocks until search regex matches or screen stabilizes (hashStableMs) - seq-based history: each content change gets a monotonic sequence number - since parameter on both tools: returns line-level diff against historical frame - Ring buffer of 200 deduped snapshots per session for diffing - LineDiff format: changed/added/removed lines with content and line numbers
1 parent 45ebfb5 commit 1abc157

8 files changed

Lines changed: 406 additions & 13 deletions

File tree

src/plugin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ptyRead } from './plugin/pty/tools/read.ts'
77
import { ptyList } from './plugin/pty/tools/list.ts'
88
import { ptyKill } from './plugin/pty/tools/kill.ts'
99
import { ptySnapshot } from './plugin/pty/tools/snapshot.ts'
10+
import { ptySnapshotWait } from './plugin/pty/tools/snapshot-wait.ts'
1011
import { PTYServer } from './web/server/server.ts'
1112
import open from 'open'
1213

@@ -50,6 +51,7 @@ export const PTYPlugin = async ({ client, directory }: PluginContext): Promise<P
5051
pty_write: ptyWrite,
5152
pty_read: ptyRead,
5253
pty_snapshot: ptySnapshot,
54+
pty_snapshot_wait: ptySnapshotWait,
5355
pty_list: ptyList,
5456
pty_kill: ptyKill,
5557
},

src/plugin/pty/manager.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { version as bunPtyVersion } from 'bun-pty/package.json'
55
import { NotificationManager } from './notification-manager.ts'
66
import { OutputManager } from './output-manager.ts'
77
import { SessionLifecycleManager } from './session-lifecycle.ts'
8+
import type { SnapshotDiff, WaitCondition, WaitResult } from './snapshot.ts'
89
import type {
910
PTYSessionInfo,
11+
PTYStatus,
1012
ReadResult,
1113
SearchResult,
1214
SnapshotResult,
@@ -169,6 +171,24 @@ class PTYManager {
169171
return withSession(this.lifecycleManager, id, (session) => this.outputManager.snapshot(session), null)
170172
}
171173

174+
snapshotDiff(id: string, sinceSeq: number): (SnapshotDiff & { id: string; status: PTYStatus }) | null {
175+
return withSession(
176+
this.lifecycleManager,
177+
id,
178+
(session) => this.outputManager.snapshotDiff(session, sinceSeq),
179+
null
180+
)
181+
}
182+
183+
async snapshotWait(
184+
id: string,
185+
condition: WaitCondition
186+
): Promise<(WaitResult & { id: string; status: string }) | null> {
187+
const session = this.lifecycleManager.getSession(id)
188+
if (!session) return null
189+
return this.outputManager.snapshotWait(session, condition)
190+
}
191+
172192
kill(id: string, cleanup: boolean = false): boolean {
173193
return this.lifecycleManager.kill(id, cleanup)
174194
}

src/plugin/pty/output-manager.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { PTYSession, ReadResult, SearchResult, SnapshotResult } from './types.ts'
1+
import type { SnapshotDiff, WaitCondition, WaitResult } from './snapshot.ts'
2+
import type { PTYSession, PTYStatus, ReadResult, SearchResult, SnapshotResult } from './types.ts'
23

34
export class OutputManager {
45
write(session: PTYSession, data: string): boolean {
@@ -34,4 +35,28 @@ export class OutputManager {
3435
...session.snapshot.getState(),
3536
}
3637
}
38+
39+
snapshotDiff(
40+
session: PTYSession,
41+
sinceSeq: number
42+
): SnapshotDiff & { id: string; status: PTYStatus } {
43+
const diff = session.snapshot.getDiff(sinceSeq)
44+
return {
45+
...diff,
46+
id: session.id,
47+
status: session.status,
48+
}
49+
}
50+
51+
async snapshotWait(
52+
session: PTYSession,
53+
condition: WaitCondition
54+
): Promise<WaitResult & { id: string; status: PTYSession['status'] }> {
55+
const result = await session.snapshot.waitForCondition(condition)
56+
return {
57+
...result,
58+
id: session.id,
59+
status: session.status,
60+
}
61+
}
3762
}

src/plugin/pty/snapshot.ts

Lines changed: 229 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,26 +21,94 @@ export interface SnapshotState {
2121
cursor: SnapshotCursor
2222
text: string
2323
contentHash: string
24+
/** Monotonically increasing sequence number. Increments only on content changes. */
25+
seq: number
26+
/** Per-line content at this snapshot, indexed by line number. */
27+
lines: string[]
2428
}
2529

30+
/** A single changed line in a diff. */
31+
export interface LineDiff {
32+
line: number
33+
/** 'changed' = content differs, 'added' = line exists in new but not old, 'removed' = line existed in old but not new */
34+
type: 'changed' | 'added' | 'removed'
35+
/** The new content (undefined for 'removed' lines). */
36+
content?: string
37+
/** The old content (undefined for 'added' lines). */
38+
old?: string
39+
}
40+
41+
export interface SnapshotDiff {
42+
/** The snapshot state at the current moment. */
43+
state: SnapshotState
44+
/** The seq we diffed against. */
45+
sinceSeq: number
46+
/** Individual line-level changes. */
47+
changes: LineDiff[]
48+
/** True if the requested sinceSeq was not found in history (full snapshot returned instead). */
49+
historyTruncated: boolean
50+
}
51+
52+
export interface WaitCondition {
53+
/** RegExp to match against screen text. Resolves on first match. */
54+
search?: RegExp
55+
/** Resolve when the content hash stays unchanged for this many ms. */
56+
hashStableMs?: number
57+
/** Maximum time to wait before giving up (default: 30000). */
58+
timeoutMs?: number
59+
}
60+
61+
export interface WaitResult {
62+
/** Whether the condition was met (false = timed out). */
63+
matched: boolean
64+
/** Total wall-clock time spent waiting, in ms. */
65+
waitedMs: number
66+
/** The snapshot state at the moment of match or timeout. */
67+
state: SnapshotState
68+
}
69+
70+
/** Stored frame in the history ring buffer. */
71+
interface HistoryFrame {
72+
seq: number
73+
contentHash: string
74+
lines: string[]
75+
}
76+
77+
const DEFAULT_HISTORY_CAPACITY = 200
78+
2679
/**
2780
* Maintains a headless xterm.js terminal that mirrors PTY output,
2881
* providing parsed screen state (visible text, cursor, dimensions)
2982
* without any ANSI escape code noise.
83+
*
84+
* Also maintains a ring buffer of deduped snapshot frames (keyed by
85+
* content hash) for seq-based diffing.
3086
*/
3187
export class TerminalSnapshot {
3288
private readonly terminal: Terminal
3389
private pendingWrite: Promise<void> = Promise.resolve()
3490
private cachedState: SnapshotState
3591

36-
constructor(cols: number, rows: number, scrollback: number = rows * 10) {
92+
// Seq-based history
93+
private seq = 0
94+
private history: HistoryFrame[] = []
95+
private historyCapacity: number
96+
97+
constructor(
98+
cols: number,
99+
rows: number,
100+
scrollback: number = rows * 10,
101+
historyCapacity: number = DEFAULT_HISTORY_CAPACITY
102+
) {
37103
this.terminal = new Terminal({
38104
cols,
39105
rows,
40106
scrollback,
41107
allowProposedApi: true,
42108
})
109+
this.historyCapacity = historyCapacity
43110
this.cachedState = this.buildState()
111+
this.pushHistory(this.cachedState)
44112
}
45113

46114
/**
@@ -50,14 +118,22 @@ export class TerminalSnapshot {
50118
* cached state is always built from fully-parsed output.
51119
*/
52120
write(data: string): void {
53-
this.pendingWrite = this.pendingWrite.then(
54-
() =>
55-
new Promise<void>((resolve) => {
56-
this.terminal.write(data, resolve)
57-
})
58-
).then(() => {
59-
this.cachedState = this.buildState()
60-
})
121+
this.pendingWrite = this.pendingWrite
122+
.then(
123+
() =>
124+
new Promise<void>((resolve) => {
125+
this.terminal.write(data, resolve)
126+
})
127+
)
128+
.then(() => {
129+
const newState = this.buildState()
130+
if (newState.contentHash !== this.cachedState.contentHash) {
131+
this.seq++
132+
newState.seq = this.seq
133+
this.pushHistory(newState)
134+
}
135+
this.cachedState = newState
136+
})
61137
}
62138

63139
getState(): SnapshotState {
@@ -69,6 +145,119 @@ export class TerminalSnapshot {
69145
return this.cachedState
70146
}
71147

148+
/**
149+
* Get the current snapshot with a line-level diff against a previous seq.
150+
* If `sinceSeq` is not found in history, returns the full state with
151+
* `historyTruncated: true`.
152+
*/
153+
getDiff(sinceSeq: number): SnapshotDiff {
154+
const current = this.cachedState
155+
const oldFrame = this.history.find((f) => f.seq === sinceSeq)
156+
157+
if (!oldFrame) {
158+
// History truncated or invalid seq - return full state as "all added"
159+
const changes: LineDiff[] = []
160+
for (let i = 0; i < current.lines.length; i++) {
161+
const content = current.lines[i]!
162+
if (content.trim()) {
163+
changes.push({ line: i, type: 'added', content })
164+
}
165+
}
166+
167+
return {
168+
state: current,
169+
sinceSeq,
170+
changes,
171+
historyTruncated: true,
172+
}
173+
}
174+
175+
const changes = computeLineDiff(oldFrame.lines, current.lines)
176+
177+
return {
178+
state: current,
179+
sinceSeq: oldFrame.seq,
180+
changes,
181+
historyTruncated: false,
182+
}
183+
}
184+
185+
/**
186+
* Polls the terminal state until a condition is met or timeout is reached.
187+
* The polling loop runs server-side so the calling agent pays for only one
188+
* tool invocation instead of many.
189+
*
190+
* Supported conditions (at least one must be provided):
191+
* - `search`: resolves when screen text matches the regex
192+
* - `hashStableMs`: resolves when content hash is unchanged for N ms
193+
*
194+
* If both are provided, the first condition to match wins.
195+
*/
196+
async waitForCondition(condition: WaitCondition): Promise<WaitResult> {
197+
const POLL_INTERVAL_MS = 100
198+
const timeoutMs = condition.timeoutMs ?? 30_000
199+
const start = Date.now()
200+
201+
let lastHash = this.cachedState.contentHash
202+
let lastHashChangeTime = start
203+
204+
const check = (): WaitResult | null => {
205+
const state = this.cachedState
206+
207+
// search condition
208+
if (condition.search && condition.search.test(state.text)) {
209+
return { matched: true, waitedMs: Date.now() - start, state }
210+
}
211+
212+
// hashStable condition: track when hash last changed
213+
if (condition.hashStableMs != null) {
214+
if (state.contentHash !== lastHash) {
215+
lastHash = state.contentHash
216+
lastHashChangeTime = Date.now()
217+
} else if (Date.now() - lastHashChangeTime >= condition.hashStableMs) {
218+
return { matched: true, waitedMs: Date.now() - start, state }
219+
}
220+
}
221+
222+
return null
223+
}
224+
225+
// Check immediately before entering the poll loop
226+
const immediate = check()
227+
if (immediate) return immediate
228+
229+
return new Promise<WaitResult>((resolve) => {
230+
const interval = setInterval(() => {
231+
const result = check()
232+
if (result) {
233+
clearInterval(interval)
234+
resolve(result)
235+
return
236+
}
237+
if (Date.now() - start >= timeoutMs) {
238+
clearInterval(interval)
239+
resolve({
240+
matched: false,
241+
waitedMs: Date.now() - start,
242+
state: this.cachedState,
243+
})
244+
}
245+
}, POLL_INTERVAL_MS)
246+
})
247+
}
248+
249+
private pushHistory(state: SnapshotState): void {
250+
this.history.push({
251+
seq: state.seq,
252+
contentHash: state.contentHash,
253+
lines: state.lines,
254+
})
255+
// Evict oldest if over capacity
256+
while (this.history.length > this.historyCapacity) {
257+
this.history.shift()
258+
}
259+
}
260+
72261
private buildState(): SnapshotState {
73262
const buffer = this.terminal.buffer.active
74263
const startLine = buffer.viewportY
@@ -94,8 +283,38 @@ export class TerminalSnapshot {
94283
},
95284
text,
96285
contentHash: computeContentHash(text),
286+
seq: this.seq,
287+
lines,
288+
}
289+
}
290+
}
291+
292+
/** Compute line-level diff between old and new screen content. */
293+
function computeLineDiff(oldLines: string[], newLines: string[]): LineDiff[] {
294+
const changes: LineDiff[] = []
295+
const maxLen = Math.max(oldLines.length, newLines.length)
296+
297+
for (let i = 0; i < maxLen; i++) {
298+
const oldLine = i < oldLines.length ? oldLines[i] : undefined
299+
const newLine = i < newLines.length ? newLines[i] : undefined
300+
301+
if (oldLine === undefined && newLine !== undefined) {
302+
// Line added (screen grew)
303+
if (newLine.trim()) {
304+
changes.push({ line: i, type: 'added', content: newLine })
305+
}
306+
} else if (oldLine !== undefined && newLine === undefined) {
307+
// Line removed (screen shrank)
308+
if (oldLine.trim()) {
309+
changes.push({ line: i, type: 'removed', old: oldLine })
310+
}
311+
} else if (oldLine !== newLine) {
312+
// Content changed
313+
changes.push({ line: i, type: 'changed', content: newLine, old: oldLine })
97314
}
98315
}
316+
317+
return changes
99318
}
100319

101320
/** FNV-1a 64-bit hash for fast, stable change detection of screen content. */
@@ -106,4 +325,4 @@ function computeContentHash(text: string): string {
106325
hash = (hash * FNV_PRIME) & FNV_MASK
107326
}
108327
return hash.toString()
109-
}
328+
}

0 commit comments

Comments
 (0)