Skip to content
Closed
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
3 changes: 3 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
- Fixed configured model discovery caches to refresh when `models.yml`/`models.json` is newer than the cached row, so updated local model metadata is not shadowed by fresh `models.db` entries. ([#3242](https://github.com/can1357/oh-my-pi/issues/3242))
- Fixed hide-secrets handling so advisor session updates are redacted before the advisor model sees them and opaque assistant thinking blocks are no longer deobfuscated.
- Filtered alias definitions brush's whitespace-only expander cannot execute (`(`, `)`, `|`, `&`, `;`, `<`, `>`, `` ` ``) from the bash-tool shell snapshot, so user rc-files containing compound aliases like Fedora's default `which='(alias; declare -f) | /usr/bin/which …'` no longer poison the brush session with `error: command not found: (alias;` ([#3234](https://github.com/can1357/oh-my-pi/issues/3234)).
### Fixed

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix: This adds a second ### Fixed inside the already-released ## [16.1.15] section. Repo convention requires new entries under ## [Unreleased] and released sections are immutable; this entry should be moved up to the existing Unreleased ### Fixed block at lines 9-15.


- Fixed large parked-agent/advisor transcript viewers and live chat rebuilds stalling on long sessions by tailing appended session JSONL, preserving partial writes, and collapsing compacted display history for hot TUI surfaces.

## [16.1.14] - 2026-06-22

Expand Down
234 changes: 184 additions & 50 deletions packages/coding-agent/src/modes/components/agent-transcript-viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,26 @@
* compositing into the live transcript's scrollback. It renders a parked
* subagent / advisor / collab-guest transcript that has no live in-view session.
*
* The transcript is rebuilt from scratch on every refresh ({@link ChatTranscriptBuilder.rebuild})
* rather than synced incrementally, so a growing file-backed transcript (the
* advisor appends while you watch) can never duplicate or misorder rows. Scroll
* is owned end-to-end by a single {@link ScrollView}; the viewer follows the tail
* until the reader scrolls up.
* The viewer tails append-only JSONL when possible and falls back to a full
* compaction-aware rebuild when file identity changes, content is replaced, or a
* structural session entry arrives. Scroll is owned end-to-end by a single
* {@link ScrollView}; the viewer follows the tail until the reader scrolls up.
*
* Local agents re-read the whole session file whenever its size or mtime changes
* (covering SessionManager's in-place rewrites, not just appends). Collab guests
* keep the incremental byte cursor the host's capped `readTranscript` requires
* and rebuild components from the accumulated entries.
* Local agents read only newly appended bytes for normal writes while preserving
* an incomplete trailing JSONL line across polls. Collab guests keep the
* incremental byte cursor the host's capped `readTranscript` requires and clear
* stale rows when the host reports rotation.
*/
import * as fs from "node:fs";
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core";
import { type Component, Editor, matchesKey, parseSgrMouse, ScrollView, type TUI } from "@oh-my-pi/pi-tui";
import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils";
import type { KeyId } from "../../config/keybindings";
import type { MessageRenderer } from "../../extensibility/extensions/types";
import type { AgentLifecycleManager } from "../../registry/agent-lifecycle";
import type { AgentRegistry, AgentStatus } from "../../registry/agent-registry";
import type { FileEntry, SessionMessageEntry } from "../../session/session-entries";
import { buildSessionContext } from "../../session/session-context";
import type { FileEntry, SessionEntry } from "../../session/session-entries";
import { parseSessionEntries } from "../../session/session-loader";
import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
import { getEditorTheme, theme } from "../theme/theme";
Expand Down Expand Up @@ -64,6 +64,27 @@ export interface AgentTranscriptViewerDeps {
/** How often to re-stat a file-backed transcript for growth (advisor/live tail). */
const POLL_MS = 250;

type LocalTailState = {
path: string;
dev: number;
ino: number;
size: number;
mtimeMs: number;
ctimeMs: number;
offset: number;
pending: string;
};

function splitCompleteJsonl(text: string): { complete: string; pending: string } {
const lastNewline = text.lastIndexOf("\n");
if (lastNewline < 0) return { complete: "", pending: text };
return { complete: text.slice(0, lastNewline + 1), pending: text.slice(lastNewline + 1) };
}

function isSessionEntry(entry: FileEntry): entry is SessionEntry {
return entry.type !== "session";
}

function statusBadge(status: AgentStatus): string {
switch (status) {
case "running":
Expand All @@ -85,10 +106,12 @@ export class AgentTranscriptViewer implements Component {
#notice: string | undefined;
#expanded = false;

// Local file transcript state: re-read when the file size or mtime changes.
#lastSignature = "";
// Local file transcript state: append-tail same-inode growth; rebuild on replacement.
#localState: LocalTailState | undefined;
#localEmptyReason: "none" | "missing" | undefined;
// Remote transcript state (incremental; the host caps each read).
#remoteEntries: SessionMessageEntry[] = [];
#remoteEntries: FileEntry[] = [];
#remotePending = "";
#remoteBytes = 0;
#remoteFetchInFlight = false;
#remoteToken = 0;
Expand Down Expand Up @@ -145,7 +168,7 @@ export class AgentTranscriptViewer implements Component {
// Transcript loading
// ========================================================================

/** Re-read the transcript and rebuild components when it changed. */
/** Tail the transcript and rebuild components only when necessary. */
#refresh(): void {
if (this.#disposed) return;
if (this.deps.remote) {
Expand All @@ -154,39 +177,99 @@ export class AgentTranscriptViewer implements Component {
}
const sessionFile = this.deps.registry.get(this.deps.agentId)?.sessionFile;
if (!sessionFile) {
if (this.#lastSignature !== "none") {
this.#lastSignature = "none";
this.#rebuild([]);
if (this.#localEmptyReason !== "none") {
this.#localState = undefined;
this.#localEmptyReason = "none";
this.#model = undefined;
this.#rebuildMessages([]);
}
return;
}
let signature: string;
let stat: fs.Stats;
try {
const stat = fs.statSync(sessionFile);
// Include the path: a different file with the same size/mtime must not alias.
signature = `${sessionFile}:${stat.size}:${stat.mtimeMs}`;
stat = fs.statSync(sessionFile);
} catch {
// File deleted/rotated while open (e.g. the owning session was dropped):
// clear stale content once instead of freezing on it forever.
if (this.#lastSignature !== "missing") {
this.#lastSignature = "missing";
if (this.#localEmptyReason !== "missing") {
this.#localState = undefined;
this.#localEmptyReason = "missing";
this.#model = undefined;
this.#rebuild([]);
this.#rebuildMessages([]);
}
return;
}
if (signature === this.#lastSignature) return;
let text: string;
this.#localEmptyReason = undefined;
const state = this.#localState;
const identityChanged = !state || state.path !== sessionFile || state.dev !== stat.dev || state.ino !== stat.ino;
const contentReplaced =
state &&
state.path === sessionFile &&
state.dev === stat.dev &&
state.ino === stat.ino &&
stat.size === state.size &&
(stat.mtimeMs !== state.mtimeMs || stat.ctimeMs !== state.ctimeMs);
if (identityChanged || stat.size < (state?.offset ?? 0) || contentReplaced) {
this.#loadLocalFull(sessionFile, stat);
return;
}
if (!state || stat.size === state.offset) return;
let fd: number | undefined;
try {
fd = fs.openSync(sessionFile, "r");
const length = stat.size - state.offset;
const buffer = Buffer.allocUnsafe(length);
fs.readSync(fd, buffer, 0, length, state.offset);
const { complete, pending } = splitCompleteJsonl(state.pending + buffer.toString("utf-8"));
Comment on lines +220 to +221

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Decode only bytes actually read

When the transcript is replaced or truncated between statSync and readSync (now plausible because rewrites are rename-based), readSync can return fewer bytes than the requested length; decoding the entire Buffer.allocUnsafe(length) then feeds uninitialized bytes into JSONL splitting/parsing and can render bogus rows or advance the tail past unread data. Capture the returned byte count and decode only buffer.subarray(0, bytesRead).

Useful? React with 👍 / 👎.

const entries = complete ? parseSessionEntries(complete) : [];
this.#localState = {
...state,
size: stat.size,
mtimeMs: stat.mtimeMs,
ctimeMs: stat.ctimeMs,
offset: stat.size,
pending,
};
const incremental = this.#incrementalMessages(entries);
if (incremental) {
this.#appendMessages(incremental);
} else {
this.#loadLocalFull(sessionFile, stat);
}
} catch (err) {
logger.debug("transcript viewer: append read failed", { err: String(err) });
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
}

#loadLocalFull(sessionFile: string, stat: fs.Stats): void {
let data: Buffer;
try {
text = fs.readFileSync(sessionFile, "utf-8");
data = fs.readFileSync(sessionFile);
} catch (err) {
// Leave #lastSignature unchanged so a transient read error retries next poll.
logger.debug("transcript viewer: read failed", { err: String(err) });
return;
}
this.#lastSignature = signature;
const { complete, pending } = splitCompleteJsonl(data.toString("utf-8"));
const entries = complete ? parseSessionEntries(complete) : [];
this.#model = undefined;
this.#rebuild(this.#extractMessages(parseSessionEntries(text)));
this.#scanModel(entries);
this.#rebuildMessages(this.#messagesFromEntries(entries));
let nextStat = stat;
try {
nextStat = fs.statSync(sessionFile);
} catch {
nextStat = stat;
}
this.#localState = {
path: sessionFile,
dev: nextStat.dev,
ino: nextStat.ino,
size: data.byteLength,
mtimeMs: nextStat.mtimeMs,
ctimeMs: nextStat.ctimeMs,
offset: data.byteLength,
pending,
};
}

#fetchRemote(): void {
Expand All @@ -209,27 +292,39 @@ export class AgentTranscriptViewer implements Component {
return;
}
if (result.newSize < fromByte) {
// Host transcript rotated/truncated — restart from 0.
// Host transcript rotated/truncated — clear stale rows and restart from 0.
this.#remoteBytes = 0;
this.#remotePending = "";
this.#remoteEntries = [];
this.#hasRemoteData = false;
this.#rebuildMessages([]);
this.#fetchRemote();
return;
}
this.#remoteUnavailable = false;
const firstData = !this.#hasRemoteData;
this.#hasRemoteData = true;
const lastNewline = result.text.lastIndexOf("\n");
if (lastNewline >= 0) {
const completeChunk = result.text.slice(0, lastNewline + 1);
this.#remoteBytes = fromByte + Buffer.byteLength(completeChunk, "utf-8");
const parsed = this.#extractMessages(parseSessionEntries(completeChunk));
if (parsed.length > 0) {
this.#remoteEntries.push(...parsed);
this.#rebuild(this.#remoteEntries);
return;
const { complete, pending } = splitCompleteJsonl(this.#remotePending + result.text);
const parsed = complete ? parseSessionEntries(complete) : [];
this.#remotePending = pending;
this.#remoteBytes = result.newSize;
if (parsed.length > 0) {
this.#remoteEntries.push(...parsed);
const incremental = this.#incrementalMessages(parsed);
if (incremental) {
if (incremental.length > 0) {
this.#appendMessages(incremental);
} else if (firstData) {
this.deps.requestRender();
}
} else {
this.#model = undefined;
this.#scanModel(this.#remoteEntries);
this.#rebuildMessages(this.#messagesFromEntries(this.#remoteEntries));
}
return;
}
// First completed fetch (even empty) clears the "Loading…" placeholder.
// First completed fetch (even empty/header-only) clears the "Loading…" placeholder.
if (firstData) this.deps.requestRender();
})
.catch((error: unknown) => {
Expand All @@ -238,22 +333,60 @@ export class AgentTranscriptViewer implements Component {
});
}

/** Filter to message entries, tracking the model from the first assistant / a model_change. */
#extractMessages(entries: FileEntry[]): SessionMessageEntry[] {
const messages: SessionMessageEntry[] = [];
#messagesFromEntries(entries: readonly FileEntry[]): AgentMessage[] {
const sessionEntries = entries.filter(isSessionEntry);
// Display semantics differ from LLM context: a parked/advisor transcript must
// keep pending (resultless) tool calls visible. buildSessionContext strips
// dangling tool_use blocks, so only route through it when a compaction is
// present (where collapse is required); otherwise map messages verbatim.
if (!sessionEntries.some(entry => entry.type === "compaction")) {
const messages: AgentMessage[] = [];
for (const entry of sessionEntries) {
if (entry.type === "message") messages.push(entry.message);
}
return messages;
}
return buildSessionContext(sessionEntries, undefined, undefined, {
transcript: true,
collapseCompactedHistory: true,
}).messages;
Comment on lines +336 to +352

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Migrate entries before building transcript context

When Agent Hub opens a legacy transcript file (header has no version or version < 2), parseSessionEntries() returns entries without the id/parentId tree fields, but this new full-rebuild path feeds them directly into buildSessionContext() instead of running the normal session migration first. In that case buildSessionContext() treats the last entry as the leaf and can only build a one-entry path, so initial loads, file replacements, or structural-entry rebuilds show only the final row instead of the full parked/advisor transcript; other read-only session loaders call migrateToCurrentVersion() before building context.

Useful? React with 👍 / 👎.

Comment on lines +349 to +352

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve pending tools after compacted rebuilds

For any transcript that already contains a compaction, this full-rebuild path routes the display through buildSessionContext, whose final cleanup strips assistant toolCall blocks without matching toolResults. If a compacted advisor/parked agent is opened or its file is replaced while it is currently between a tool call and result, the pending tool spinner/assistant turn disappears until the result is written, unlike the non-compacted path that maps messages verbatim. The display collapse needs to preserve dangling tool calls for transcript viewers.

Useful? React with 👍 / 👎.

}

/** Return appendable messages, or undefined when a structural entry requires rebuild. */
#incrementalMessages(entries: readonly FileEntry[]): AgentMessage[] | undefined {
const messages: AgentMessage[] = [];
for (const entry of entries) {
if (entry.type === "session") continue;
if (entry.type === "message") {
messages.push(entry);
messages.push(entry.message);
if (!this.#model && entry.message.role === "assistant") this.#model = entry.message.model;
} else if (entry.type === "model_change") {
this.#model = entry.model;
Comment on lines 363 to 364

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix: A model_change append mutates #model here but returns an empty messages array, so the callers at #refresh()/#fetchRemote() go through #appendMessages([]) and never call requestRender() (except the remote first-fetch special case). The header model badge at #headerLines() stays stale until a later message arrives; either treat model-only chunks as a render-worthy incremental update or make the caller request a render when #incrementalMessages() consumed only metadata.

} else {
return undefined;
}
}
return messages;
}

#rebuild(entries: SessionMessageEntry[]): void {
this.#builder.rebuild(entries);
#scanModel(entries: readonly FileEntry[]): void {
for (const entry of entries) {
if (entry.type === "message" && !this.#model && entry.message.role === "assistant") {
this.#model = entry.message.model;
} else if (entry.type === "model_change") {
this.#model = entry.model;
}
}
}

#rebuildMessages(messages: readonly AgentMessage[]): void {
this.#builder.rebuildMessages(messages);
this.deps.requestRender();
}

#appendMessages(messages: readonly AgentMessage[]): void {
if (messages.length === 0) return;
this.#builder.appendMessages(messages);
this.deps.requestRender();
}

Expand Down Expand Up @@ -457,6 +590,7 @@ export class AgentTranscriptViewer implements Component {
#placeholder(): string {
if (this.deps.remote && this.#remoteUnavailable) return "Transcript lives on the host — not available.";
if (this.deps.remote && !this.#hasRemoteData) return "Loading transcript from host…";
if (this.deps.remote) return "No messages yet.";
if (!this.deps.registry.get(this.deps.agentId)?.sessionFile) return "No session file available yet.";
return "No messages yet.";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@
* viewer ({@link AgentTranscriptViewer}) to render a parked subagent / advisor /
* collab-guest transcript that has no live session.
*
* Unlike the old incremental hub sync, {@link ChatTranscriptBuilder.rebuild}
* always discards prior components and rebuilds the whole transcript from the
* supplied entries. Re-rendering a growing transcript is therefore O(n) in the
* entry count, but it cannot duplicate or misorder rows the way incremental
* component reuse could.
* {@link ChatTranscriptBuilder.append} tails new messages through the same
* per-message path used by full rebuilds. Identity changes still rebuild from
* scratch; append-only refreshes avoid re-rendering old rows.
*/
import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core";
import type { Usage } from "@oh-my-pi/pi-ai";
Expand Down Expand Up @@ -94,9 +92,25 @@ export class ChatTranscriptBuilder {
}

/** Discard all components and rebuild the whole transcript from `entries`. */
rebuild(entries: SessionMessageEntry[]): void {
rebuild(entries: readonly SessionMessageEntry[]): void {
this.reset();
for (const entry of entries) this.#appendChatMessage(entry.message);
this.append(entries);
}

/** Append persisted session entries to the existing transcript. */
append(entries: readonly SessionMessageEntry[]): void {
this.appendMessages(entries.map(entry => entry.message));
}

/** Discard all components and rebuild the whole transcript from messages. */
rebuildMessages(messages: readonly AgentMessage[]): void {
this.reset();
this.appendMessages(messages);
}

/** Append messages to the existing transcript using the normal render path. */
appendMessages(messages: readonly AgentMessage[]): void {
for (const message of messages) this.#appendChatMessage(message);
// Flush the trailing turn's usage row only once its tools are materialized
// (a read whose result has not arrived stays pending); otherwise the row
// would sit above its tools. The drain happens here at the end of the pass.
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/src/modes/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1443,7 +1443,7 @@ export class InteractiveMode implements InteractiveModeContext {
this.chatContainer.clear();
// Full-history transcript: compactions render as inline dividers instead
// of restarting the visible conversation (the LLM context still resets).
const context = this.viewSession.buildTranscriptSessionContext();
const context = this.viewSession.buildTranscriptSessionContext({ collapseCompactedHistory: true });
this.renderSessionContext(context);
// During the pre-streaming window — after `startPendingSubmission` has
// optimistically rendered the user's message but before the user
Expand Down
Loading
Loading