/**
* Guest side of a collab live session.
*
* `/join ` writes the host's snapshot to a replica session file and
* drives it through the normal `/resume` machinery, then applies live frames:
* entries → SessionManager + agent.replaceMessages, events →
* EventController.handleEvent, state → status-line overrides plus real
* model/thinking state applied to the replica agent. Lifecycle-relevant
* mirrored events additionally reach the local extension runner, so
* lifecycle integrations (Herdr pane state, RPC trackers, stats) observe host
* activity even though the guest's own agent loop never runs. The host's
* subagent ecosystem is mirrored too: agent snapshots populate a local
* AgentRegistry (Agent Hub), EventBus traffic (observer HUD) is republished,
* and hub actions (chat/kill/revive/transcript reads) round-trip over the
* wire. Host ask dialogs (`ui-request` select/editor) present through the
* same hook selector/editor seam and answer with `ui-response`;
* `ui-request-end` dismisses a pending presentation without responding.
* Everything renders through the same components, so ctrl+o, theming, and
* transcript behavior are native by construction.
*/
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage, AssistantMessageEvent, ImageContent } from "@oh-my-pi/pi-ai";
import { getConfigRootDir, logger, toError } from "@oh-my-pi/pi-utils";
import type { AgentHubRemote, AgentHubRemoteTranscript } from "@oh-my-pi/pi-tui/overlays/agent-hub";
import type { InteractiveModeContext } from "../modes/types";
import { AgentRegistry } from "../registry/agent-registry";
import type { AgentSessionEvent } from "../session/agent-session";
import type { SessionEntry } from "../session/session-entries";
import { mintSessionId, SessionManager } from "../session/session-manager";
import { FileSessionStorage } from "../session/session-storage";
import { shouldDisableReasoning, toReasoningEffort } from "@oh-my-pi/pi-tui/thinking";
import { emitSubagentFrame } from "../utils/event-bus";
import { GuestLifecycleEmitter } from "../extensibility/extensions/lifecycle-mirror";
import { setSessionTerminalTitle } from "../utils/title-generator";
import { importRoomKey } from "./crypto";
import { collabDisplayName } from "./display-name";
import {
type AgentSnapshot,
COLLAB_PROTO,
type CollabFrame,
type CollabSessionState,
type CollabUiRequest,
parseCollabLink,
} from "./protocol";
import { CollabSocket } from "./relay-client";
/** Commands a guest may run locally; everything else is host-only. */
export const COLLAB_GUEST_ALLOWED_COMMANDS: Record = {
dump: true,
export: true,
copy: true,
// Opens a link in the guest's own browser; nothing reaches the host.
open: true,
help: true,
hotkeys: true,
theme: true,
settings: true,
leave: true,
collab: true,
exit: true,
quit: true,
};
/**
* How long the guest waits for the host's small `welcome` frame before giving
* up on the join. The welcome carries metadata only (`entryCount`, header,
* state, agents), so it lands well under one second on any working relay.
*/
const WELCOME_TIMEOUT_MS = 30_000;
/**
* How long the guest waits between `snapshot-chunk` frames during the initial
* sync. Resets on each chunk arrival, so a multi-MB snapshot only fails when
* the relay genuinely stalls — not because the total wall-clock crossed the
* welcome budget. The default relay sustains ~350 KB/s; a 512 KB chunk lands
* in under two seconds with comfortable headroom.
*/
const SNAPSHOT_PROGRESS_TIMEOUT_MS = 30_000;
const TRANSCRIPT_TIMEOUT_MS = 20_000;
type WelcomeFrame = Extract;
type SnapshotChunkFrame = Extract;
/** Accumulator for an in-flight chunked welcome — see {@link CollabGuestLink}. */
interface PendingSnapshot {
header: WelcomeFrame["header"];
state: WelcomeFrame["state"];
agents: AgentSnapshot[];
readOnly: boolean;
entryCount: number;
entries: SessionEntry[];
isResync: boolean;
}
/** Minimal context surface the idle-state reconciler mutates. */
export interface GuestIdleReconcilerCtx {
statusLine: { markActivityEnd: () => void };
statusContainer: Pick;
loadingAnimation: { stop: () => void } | undefined;
}
/**
* Close the guest UI state held open by an earlier `agent_start` whose
* matching `agent_end` never reached us — most often because a reconnect
* dropped the event mid-stream. Reached via {@link reconcileGuestSnapshotHostState}
* (the live `state`-frame and welcome/resync reconciler) when the host reports `isStreaming === false`:
* folds the in-flight active-time window into the per-session meter (so
* `time_spent` stops ticking) and stops the `Working…` loader if one is
* still animating. No-op when the host is still streaming.
*
* Exported for direct unit testing; mutates the loader field on `ctx` so
* the same loader is not stopped twice on subsequent reconciliations.
*/
export function reconcileGuestIdleHostState(ctx: GuestIdleReconcilerCtx, isStreaming: boolean): void {
if (isStreaming) return;
ctx.statusLine.markActivityEnd();
if (ctx.loadingAnimation) {
ctx.loadingAnimation.stop();
ctx.loadingAnimation = undefined;
ctx.statusContainer.disposeChildren();
}
}
/** Reconcile a welcome/resync snapshot's host activity state into the guest meter. */
export interface GuestSnapshotActivityReconcilerCtx extends GuestIdleReconcilerCtx {
statusLine: GuestIdleReconcilerCtx["statusLine"] & { markActivityStart: () => void };
/**
* Start (or re-attach) the live "Working…" loader. Mirrors
* `InteractiveModeContext.ensureLoadingAnimation`, which is what
* `EventController` calls on `agent_start`. Required so a guest that
* missed an earlier `agent_start` (a reconnect dropped it mid-stream)
* starts its spinner when the host later reports it is streaming.
*/
ensureLoadingAnimation: InteractiveModeContext["ensureLoadingAnimation"];
autoCompactionLoader: InteractiveModeContext["autoCompactionLoader"];
retryLoader: InteractiveModeContext["retryLoader"];
}
/** Status-area state which cannot outlive removal of its child components. */
export interface GuestTransientStatusCtx {
statusContainer: Pick;
autoCompactionLoader: InteractiveModeContext["autoCompactionLoader"];
retryLoader: InteractiveModeContext["retryLoader"];
}
/** Stop and forget status-area loaders before detaching their components. */
export function clearGuestTransientStatus(ctx: GuestTransientStatusCtx): void {
if (ctx.autoCompactionLoader) {
ctx.autoCompactionLoader.stop();
ctx.autoCompactionLoader = undefined;
}
if (ctx.retryLoader) {
ctx.retryLoader.stop();
ctx.retryLoader = undefined;
}
ctx.statusContainer.clear();
}
export function reconcileGuestSnapshotHostState(ctx: GuestSnapshotActivityReconcilerCtx, isStreaming: boolean): void {
if (isStreaming) {
ctx.statusLine.markActivityStart();
if (!ctx.autoCompactionLoader && !ctx.retryLoader) ctx.ensureLoadingAnimation();
return;
}
reconcileGuestIdleHostState(ctx, false);
}
/** Streamed text of a text or thinking block; `undefined` for blocks speech never reads. */
function streamedText(block: AssistantMessage["content"][number] | undefined): string | undefined {
if (block?.type === "text") return block.text;
if (block?.type === "thinking") return block.thinking;
return undefined;
}
/**
* Text and thinking deltas that grew `message` past `previous` without arriving
* in `own`, the update's own delta. Non-empty only when the host superseded the
* updates in between (#16956); one delta per grown block, in block order.
*/
function omittedStreamDeltas(
previous: AssistantMessage,
message: AssistantMessage,
own: AssistantMessageEvent,
): AssistantMessageEvent[] {
const omitted: AssistantMessageEvent[] = [];
for (const [contentIndex, block] of message.content.entries()) {
const text = streamedText(block);
if (text === undefined) continue;
const prior = previous.content[contentIndex];
const start = prior?.type === block.type ? (streamedText(prior)?.length ?? 0) : 0;
const ownsTail =
(own.type === "text_delta" || own.type === "thinking_delta") &&
own.contentIndex === contentIndex &&
own.type === `${block.type}_delta`;
const end = text.length - (ownsTail ? own.delta.length : 0);
if (end <= start) continue;
const delta = text.slice(start, end);
omitted.push(
block.type === "text"
? { type: "text_delta", contentIndex, delta, partial: message }
: { type: "thinking_delta", contentIndex, delta, partial: message },
);
}
return omitted;
}
export class CollabGuestLink {
#ctx: InteractiveModeContext;
#socket: CollabSocket | null = null;
#roomId = "";
/**
* This guest's replica: its own session id and file for the guest's
* lifetime. Ownership is keyed by session id, so the replica must not reuse
* the host's id (a guest on the host's machine would contend with the
* host), must not change on a resync (a lease handoff would leave the file
* briefly unowned), and must not share a file with another local guest of
* the same room.
*/
readonly #replicaId = mintSessionId();
/**
* The replica's ownership lease, held from the first snapshot write until
* the guest has left. Opening and reloading the replica never claims it, so
* without this an idle guest would leave its live replica unowned (and
* collectable by `omp gc`).
*/
#replicaLease: (() => void) | undefined;
/** Previous session file to restore on leave; null = previous session was unsaved. */
#returnSessionFile: string | null = null;
/** Frames apply strictly in arrival order through this chain. */
#applyChain: Promise = Promise.resolve();
/** True after the initial snapshot has been written to disk and resumed. */
#welcomed = false;
#left = false;
#replicaActivated = false;
/** One owner spans cancellation, queued snapshot work, and local restoration. */
#restoration: Promise | undefined;
/**
* Buffer for the in-flight chunked welcome. Set by the small `welcome`
* frame, accumulated by every `snapshot-chunk`, drained when the final
* chunk lands (or the snapshot-progress timer fires).
*/
#pendingSnapshot: PendingSnapshot | null = null;
/**
* Fires `firstWelcome.reject` from a stalled welcome/snapshot during the
* initial join. Set in {@link join}, cleared on resolve/reject; arming a
* timer after that point is a no-op so reconnect-time stalls fall through
* to the normal socket close handling instead of aborting the live session.
*/
#joinReject: ((err: Error) => void) | null = null;
#welcomeTimer: Timer | null = null;
#snapshotProgressTimer: Timer | null = null;
/** base64url write token from a full link; absent when joined via a view link. */
#writeToken: string | undefined;
/** True when the host marked this peer read-only (view link). */
#readOnly = false;
/** False until the first assistant message_start (real or synthesized) since (re)sync. */
#assistantStreamSynced = false;
/** Last snapshot received for the streaming assistant message; diffed against the next update. */
#streamedSnapshot: AssistantMessage | undefined;
/** Mirrors host lifecycle events into the local extension runner while joined. */
#lifecycleEmitter = new GuestLifecycleEmitter();
state: CollabSessionState | null = null;
/** Local mirror of the host's agent ecosystem (refs carry `session: null`). */
readonly agentRegistry = new AgentRegistry();
/** Per-agent `hasSessionFile` from the last snapshot; gates remote transcript fetches. */
#agentHasTranscript = new Map();
#pendingTranscripts = new Map void>();
/** Host `ui-request`s presented (or queued) locally, keyed by reqId; aborting dismisses. */
#pendingUiRequests = new Map();
#nextReqId = 1;
readonly #hubRemote: AgentHubRemote = {
chat: (id, text) => {
if (this.#rejectReadOnly()) return;
this.#socket?.send({ t: "agent-cmd", cmd: "chat", agentId: id, text });
},
kill: id => {
if (this.#rejectReadOnly()) return;
this.#socket?.send({ t: "agent-cmd", cmd: "kill", agentId: id });
},
revive: id => {
if (this.#rejectReadOnly()) return;
this.#socket?.send({ t: "agent-cmd", cmd: "revive", agentId: id });
},
readTranscript: (id, fromByte) => {
const socket = this.#socket;
if (!socket || this.#agentHasTranscript.get(id) === false) {
return Promise.resolve(null);
}
const reqId = this.#nextReqId++;
const { promise, resolve } = Promise.withResolvers();
const timer = setTimeout(() => {
this.#pendingTranscripts.delete(reqId);
resolve(null);
}, TRANSCRIPT_TIMEOUT_MS);
this.#pendingTranscripts.set(reqId, result => {
clearTimeout(timer);
resolve(result);
});
socket.send({ t: "fetch-transcript", reqId, agentId: id, fromByte });
return promise;
},
};
/** Agent Hub actions routed to the host over the wire. */
get hubRemote(): AgentHubRemote {
return this.#hubRemote;
}
/** True when this guest joined through a read-only (view) link. */
get readOnly(): boolean {
return this.#readOnly;
}
/** Shows the read-only status hint when applicable; true when the action must be dropped. */
#rejectReadOnly(): boolean {
if (!this.#readOnly) return false;
this.#ctx.showStatus("This collab link is read-only");
return true;
}
constructor(ctx: InteractiveModeContext) {
this.#ctx = ctx;
}
async join(link: string): Promise {
const parsed = parseCollabLink(link);
if ("error" in parsed) throw new Error(parsed.error);
if (this.#ctx.collabGuest || this.#left) throw new Error("Already in a collab session (/leave first)");
this.#roomId = parsed.roomId;
this.#writeToken = parsed.writeToken ? Buffer.from(parsed.writeToken).toString("base64url") : undefined;
this.#returnSessionFile = this.#ctx.sessionManager.getSessionFile() ?? null;
// Claim the process before any async setup or replica session transition.
this.#ctx.setCollabGuest(this);
let key: CryptoKey;
try {
key = await importRoomKey(parsed.key);
if (this.#left) throw new Error("Collab join cancelled");
} catch (err) {
await this.#restoreLocalSession();
throw err;
}
const socket = new CollabSocket({ wsUrl: parsed.wsUrl, role: "guest", key });
this.#socket = socket;
const firstWelcome = Promise.withResolvers();
let joined = false;
this.#joinReject = err => firstWelcome.reject(err);
const finishJoin = (): void => {
if (joined || this.#left) return;
joined = true;
firstWelcome.resolve();
};
socket.onOpen = () => {
if (this.#left) return;
// (Re)connect: re-introduce ourselves; the host answers with a fresh
// welcome which (re)syncs the replica. Discard any partially-streamed
// snapshot from a prior connection: the host will resend the full
// chunk train.
this.#welcomed = false;
this.#pendingSnapshot = null;
this.#clearSnapshotProgressTimer();
this.#armWelcomeTimer();
socket.send({
t: "hello",
proto: COLLAB_PROTO,
name: collabDisplayName(this.#ctx),
writeToken: this.#writeToken,
});
};
socket.onFrame = frame => {
this.#applyChain = this.#applyChain
.then(async () => {
if (this.#left) return;
if (frame.t === "welcome") {
this.#clearWelcomeTimer();
this.#beginWelcome(frame, joined);
if (frame.entryCount === 0) {
await this.#finalizeSnapshot();
finishJoin();
}
return;
}
if (frame.t === "snapshot-chunk") {
const ready = this.#accumulateSnapshotChunk(frame);
if (ready) {
await this.#finalizeSnapshot();
finishJoin();
}
return;
}
if (frame.t === "error" && !this.#welcomed && !this.#left) {
// Pre-welcome errors are the host's targeted reply to our
// hello (e.g. protocol mismatch): no welcome will follow.
// Fail the join with the host's message instead of hanging
// until the welcome timeout.
this.#clearWelcomeTimer();
if (joined) this.#ctx.showError(`Collab host: ${frame.message}`);
else firstWelcome.reject(new Error(frame.message));
return;
}
if (!this.#welcomed || this.#left) return;
this.#applyFrame(frame);
})
.catch(err => {
logger.warn("collab guest frame apply failed", { type: frame.t, error: String(err) });
if (frame.t === "welcome" || frame.t === "snapshot-chunk") {
if (!joined) firstWelcome.reject(toError(err));
else this.#restoreAfterDisconnect();
}
});
};
socket.onClose = (reason, willReconnect) => {
this.#clearWelcomeTimer();
this.#clearSnapshotProgressTimer();
this.#flushPendingTranscripts();
if (this.#left) return;
if (!joined) {
firstWelcome.reject(new Error(reason));
return;
}
if (willReconnect) {
this.#ctx.showStatus(`Collab connection lost (${reason}), reconnecting…`, { dim: true });
return;
}
this.#ctx.showStatus(`Collab session ended (${reason})`);
this.#restoreAfterDisconnect();
};
try {
socket.connect();
// Cover a blackholed WebSocket handshake too; onOpen resets the timer.
this.#armWelcomeTimer();
await firstWelcome.promise;
if (this.#left) throw new Error("Collab join cancelled");
} catch (err) {
this.#joinReject = null;
try {
await this.#restoreLocalSession();
} catch (restoreError) {
throw new AggregateError([err, restoreError], `${err}; local restoration failed: ${restoreError}`);
}
throw err;
} finally {
this.#joinReject = null;
this.#clearWelcomeTimer();
this.#clearSnapshotProgressTimer();
}
}
/** User-initiated leave (or post-disconnect cleanup): restore the previous session. */
async leave(_reason: string): Promise {
await this.#restoreLocalSession();
}
sendPrompt(text: string, images?: ImageContent[]): void {
if (this.#rejectReadOnly()) return;
this.#socket?.send({ t: "prompt", text, images: images && images.length > 0 ? images : undefined });
}
sendAbort(): void {
if (this.#rejectReadOnly()) return;
this.#socket?.send({ t: "abort" });
}
/**
* Latch the welcome metadata and prime the snapshot accumulator. The
* heavy resume work (file write, `switchSession`, render) only happens in
* {@link #finalizeSnapshot}, so the small welcome frame clears the join
* timeout immediately even when the transcript still has to stream in.
*/
#beginWelcome(frame: WelcomeFrame, isResync: boolean): void {
if (this.#left) return;
this.#pendingSnapshot = {
header: frame.header,
state: frame.state,
agents: frame.agents,
readOnly: frame.readOnly === true,
entryCount: frame.entryCount,
entries: [],
isResync,
};
this.#armSnapshotProgressTimer();
}
/**
* Append a chunk to the pending snapshot. Returns `true` when the
* accumulator has gathered every entry the welcome promised, or the host
* tagged this chunk as `final`. The caller is responsible for invoking
* {@link #finalizeSnapshot} on the same applyChain microtask.
*/
#accumulateSnapshotChunk(frame: SnapshotChunkFrame): boolean {
const pending = this.#pendingSnapshot;
if (!pending) {
logger.debug("collab guest dropping orphan snapshot-chunk");
return false;
}
pending.entries.push(...frame.entries);
const complete = frame.final || pending.entries.length >= pending.entryCount;
if (complete) {
this.#clearSnapshotProgressTimer();
} else {
this.#armSnapshotProgressTimer();
}
return complete;
}
/** Write the accumulated welcome snapshot to the replica file and (re)load it through the resume machinery. */
async #finalizeSnapshot(): Promise {
const pending = this.#pendingSnapshot;
this.#pendingSnapshot = null;
this.#clearSnapshotProgressTimer();
if (!pending || this.#left) return;
const replicaPath = path.join(getConfigRootDir(), "collab", `${this.#roomId}-${this.#replicaId}.jsonl`);
// A child of the host's session, as a sibling move is.
const header = {
...pending.header,
id: this.#replicaId,
parentSession: pending.header.id,
providerPromptCacheKey: SessionManager.getInheritedProviderPromptCacheKey(pending.header),
};
const lines = [header, ...pending.entries].map(entry => JSON.stringify(entry)).join("\n");
const storage = new FileSessionStorage();
this.#replicaLease ??= storage.claimSession(this.#replicaId, replicaPath) ?? undefined;
// Published atomically: `omp gc` reads the replica's header to find its
// lease, so a resync must never expose a truncated, headerless file.
const tempPath = `${replicaPath}.${mintSessionId()}.tmp`;
try {
await Bun.write(tempPath, `${lines}\n`);
await fs.rename(tempPath, replicaPath);
} catch (err) {
await fs.unlink(tempPath).catch(() => {});
throw err;
}
if (this.#left) return;
// Resume through AgentSession without adopting the host's cwd. The replica
// keeps its model: #applyHostState mirrors the host's, which runs inference.
const switched = await this.#ctx.session.switchSession(replicaPath, { preserveLocalCwd: true, keepModel: true });
if (switched === false) {
throw new Error("Collab replica activation was cancelled");
}
this.#replicaActivated = true;
if (this.#left) return;
const orphanedLiveBlocks = [
...this.#ctx.pendingTools.values(),
...this.#ctx.eventController.takeDisplaceableComponents(),
];
// Drop turn-scoped controller state (coalesced message_update timer,
// in-flight anchors) so a pending pre-boundary snapshot cannot flush
// into the replacement transcript.
this.#ctx.eventController.resetTranscriptAnchors();
this.#clearTransientUi();
this.#clearAgentMirror();
this.state = pending.state;
reconcileGuestSnapshotHostState(this.#ctx, pending.state.isStreaming);
this.#reconcileLifecycle(pending.state.isStreaming);
this.#applyHostState(pending.state);
this.#ctx.resetObserverRegistry();
this.#applyAgentSnapshots(pending.agents);
this.#ctx.syncRunningSubagentBadge();
this.#assistantStreamSynced = false;
this.#streamedSnapshot = undefined;
setSessionTerminalTitle(pending.state.sessionName ?? pending.header.title, pending.state.cwd);
// No eager teardown here: renderInitialMessages() stages the replacement
// transcript and disposes the visible children only when the staged tree
// commits (ui-helpers), which both preserves its atomicity/rollback
// behavior and unregisters live tool blocks from the shared spinner
// ticker via ToolExecutionComponent.dispose().
try {
await this.#ctx.renderInitialMessages({ clearTerminalHistory: true });
} catch (err) {
// #clearTransientUi() above already dropped the pendingTools blocks,
// and #handleToolExecutionEnd settles a displaceable wait/todo result out
// of pendingTools into EventController's own trackers instead (Codex
// review on #9377): orphanedLiveBlocks folds both in via
// takeDisplaceableComponents() above, or a still-animated "waiting" card
// would survive the resync with no remaining reference to stop it. A
// failed renderInitialMessages() restores the untouched visible
// container without disposing its children (its own rollback only tears
// down the staged tree that never committed), so every orphaned block
// here is still a live, rendered row. dispose() would be wrong: it
// propagates teardown to a component's own renderer children
// (Container.dispose()), releasing resources that row's still-visible
// children may use (Codex review on #9377). seal() only unregisters the
// shared-ticker registration and stops the animation, leaving the
// rendered row and its children intact.
for (const handle of orphanedLiveBlocks) {
handle.seal();
}
throw err;
}
if (this.#left) return;
await this.#ctx.reloadTodos();
if (this.#left) return;
this.#updateStatusSegment();
this.#readOnly = pending.readOnly;
this.#welcomed = true;
const suffix = this.#readOnly ? " (read-only)" : "";
this.#ctx.showStatus(
pending.isResync ? `Reconnected to collab session${suffix}` : `Joined collab session${suffix}`,
);
}
#armWelcomeTimer(): void {
if (this.#joinReject === null) return;
this.#clearWelcomeTimer();
this.#welcomeTimer = setTimeout(() => {
this.#welcomeTimer = null;
this.#joinReject?.(new Error("timed out waiting for the host's welcome"));
}, WELCOME_TIMEOUT_MS);
}
#clearWelcomeTimer(): void {
if (this.#welcomeTimer !== null) {
clearTimeout(this.#welcomeTimer);
this.#welcomeTimer = null;
}
}
#armSnapshotProgressTimer(): void {
if (this.#joinReject === null) return;
this.#clearSnapshotProgressTimer();
this.#snapshotProgressTimer = setTimeout(() => {
this.#snapshotProgressTimer = null;
this.#joinReject?.(new Error("timed out waiting for the host's session snapshot"));
}, SNAPSHOT_PROGRESS_TIMEOUT_MS);
}
#clearSnapshotProgressTimer(): void {
if (this.#snapshotProgressTimer !== null) {
clearTimeout(this.#snapshotProgressTimer);
this.#snapshotProgressTimer = null;
}
}
#applyFrame(frame: CollabFrame): void {
switch (frame.t) {
case "entry": {
// Entries are never rendered directly — rendering is events-only
// (prevents double-render). They keep the replica file, the agent's
// message array (/dump, context estimates), and todos current.
this.#ctx.sessionManager.ingestReplicatedEntry(frame.entry);
if (frame.entry.type === "message") {
this.#ctx.session.agent.replaceMessages([...this.#ctx.session.messages, frame.entry.message]);
} else if (frame.entry.type === "compaction" || frame.entry.type === "branch_summary") {
// Compaction/branch entries rewrite the host's model context: the
// pre-boundary transcript collapses behind a summary. Appending
// the entry alone leaves the replica holding the stale full
// history, so rebuild the message array from the ingested entries
// exactly as the host does after appendCompaction/branchWithSummary
// (session-maintenance.ts, agent-session.ts).
this.#ctx.session.agent.replaceMessages(this.#ctx.session.buildDisplaySessionContext().messages);
}
break;
}
case "event":
this.#applyEvent(frame.event);
break;
case "state": {
this.state = frame.state;
this.#applyHostState(frame.state);
setSessionTerminalTitle(frame.state.sessionName, frame.state.cwd);
this.#updateStatusSegment();
reconcileGuestSnapshotHostState(this.#ctx, frame.state.isStreaming);
this.#reconcileLifecycle(frame.state.isStreaming);
this.#ctx.statusLine.invalidate();
this.#ctx.ui.requestRender();
break;
}
case "bus":
// Mirrored host EventBus traffic (task subagent lifecycle/progress)
// feeding the observer HUD and Agent Hub progress columns. The
// observer registry listens on the shared observability bus.
emitSubagentFrame(this.#ctx.eventBus, this.#ctx.subagentEventBus, frame.channel, frame.data);
break;
case "agents":
this.#applyAgentSnapshots(frame.agents);
this.#ctx.syncRunningSubagentBadge();
break;
case "ui-request":
this.#presentUiRequest(frame.request);
break;
case "ui-request-end":
this.#endUiRequest(frame.reqId);
break;
case "transcript": {
const resolve = this.#pendingTranscripts.get(frame.reqId);
if (resolve) {
this.#pendingTranscripts.delete(frame.reqId);
resolve({ text: frame.text, newSize: frame.newSize, error: frame.error });
}
break;
}
case "bye": {
this.#ctx.showStatus(`Collab session ended (${frame.reason})`);
this.#restoreAfterDisconnect();
break;
}
case "error":
this.#ctx.showError(`Collab host: ${frame.message}`);
break;
default:
logger.debug("collab guest ignoring unexpected frame", { type: frame.t });
}
}
#applyEvent(event: AgentSessionEvent): void {
// Orphan-delta guard: when joining mid-turn the message_start for the
// in-flight assistant message predates the snapshot. message_update
// carries the full accumulating message, so synthesize the missing start
// before the first orphaned update; every other handler is tolerant of
// unknown anchors (guarded by streamingComponent/pendingTools lookups).
if (event.type === "message_start" && event.message.role === "assistant") {
this.#assistantStreamSynced = true;
this.#streamedSnapshot = event.message;
} else if (event.type === "message_update" && event.message.role === "assistant") {
if (!this.#assistantStreamSynced) {
this.#assistantStreamSynced = true;
this.#streamedSnapshot = event.message;
this.#dispatchEvent({ type: "message_start", message: event.message });
}
// The host supersedes still-queued updates of a streaming message, so
// their per-update deltas (speech, extensions) never arrive. Replay the
// text they carried, recovered from the cumulative snapshots.
if (this.#streamedSnapshot) {
for (const omitted of omittedStreamDeltas(
this.#streamedSnapshot,
event.message,
event.assistantMessageEvent,
)) {
this.#mirrorEvent({ type: "message_update", message: event.message, assistantMessageEvent: omitted });
}
}
this.#streamedSnapshot = event.message;
} else if (event.type === "message_end") {
this.#streamedSnapshot = undefined;
}
this.#mirrorEvent(event);
}
#mirrorEvent(event: AgentSessionEvent): void {
this.#dispatchEvent(event);
// Lifecycle mirror: the guest's own agent loop never runs, so the session's
// extension-event path stays silent. Route the mirrored wire event through
// the same mapping the session uses so extension-installed lifecycle
// integrations (Herdr pane state, RPC trackers, stats) observe host
// working/idle transitions while joined. Emissions chain (ordered but never
// awaited inline) so a slow extension handler neither stalls frame
// application nor completes out of order.
const runner = this.#ctx.session.extensionRunner;
if (runner) this.#lifecycleEmitter.emit(runner, event);
}
/**
* Feed a mirrored event through the same pipeline as a local session:
* `message_update` joins the controller's coalesced streaming rebuild and
* every other event runs serialized behind it, so a mirrored stream tail
* cannot reorder (message_update → message_end → agent_end).
*/
#dispatchEvent(event: AgentSessionEvent): void {
this.#ctx.eventController.dispatchSessionEvent(event).catch(err => {
logger.warn("collab guest event dispatch failed", { type: event.type, error: String(err) });
});
}
/**
* Reconcile the lifecycle mirror with a host activity snapshot. A guest that
* joins (or resyncs) mid-run missed the host's `agent_start`, and the mirror
* must not assume the wire's first event after (re)sync carries the boundary;
* a host that reports `isStreaming === false` may equally have settled before
* the guest ever saw a start. Synthesize the missing edge so extension
* handlers never miss a working/idle transition. This is not an exact replay
* of a local session: a `state` frame landing during host prompt setup
* synthesizes an `agent_start` that the real one then repeats, and the
* host's intermediate `willContinue` settles never reach the wire.
*/
#reconcileLifecycle(isStreaming: boolean): void {
const runner = this.#ctx.session.extensionRunner;
if (!runner) return;
if (isStreaming) {
if (!this.#lifecycleEmitter.sawAgentStart) {
this.#lifecycleEmitter.emit(runner, { type: "agent_start" });
}
} else if (this.#lifecycleEmitter.sawAgentStart) {
this.#lifecycleEmitter.emit(runner, {
type: "agent_end",
messages: [...this.#ctx.session.messages],
isTerminal: true,
});
}
}
/**
* Apply the host's real model/thinking state to the replica agent so model
* display and context-window math are native (no display-string overrides).
* Pure agent-state mutation: session.setModel/setThinkingLevel would
* persist entries and clamp to local credentials.
*/
#applyHostState(state: CollabSessionState): void {
const session = this.#ctx.session;
if (
state.model &&
(session.agent.state.model?.id !== state.model.id ||
session.agent.state.model?.provider !== state.model.provider)
) {
session.agent.setModel(state.model);
}
const level = state.thinkingLevel as ThinkingLevel | undefined;
session.agent.setThinkingLevel(toReasoningEffort(level));
session.agent.setDisableReasoning(shouldDisableReasoning(level));
}
/** Diff a host agent snapshot into the local registry (refs keep `session: null`). */
#applyAgentSnapshots(agents: AgentSnapshot[]): void {
const seen = new Set();
for (const snap of agents) seen.add(snap.id);
for (const ref of this.agentRegistry.list()) {
if (!seen.has(ref.id)) {
this.agentRegistry.unregister(ref.id);
this.#agentHasTranscript.delete(ref.id);
}
}
for (const snap of agents) {
if (this.agentRegistry.get(snap.id)) {
this.agentRegistry.setStatus(snap.id, snap.status);
} else {
this.agentRegistry.register({
id: snap.id,
displayName: snap.displayName,
kind: snap.kind,
parentId: snap.parentId,
session: null,
status: snap.status,
});
}
// Refs are returned by reference: patch host timestamps directly so
// hub age/activity columns reflect the host, not local registration.
const ref = this.agentRegistry.get(snap.id);
if (ref) {
ref.createdAt = snap.createdAt;
ref.lastActivity = snap.lastActivity;
ref.displayName = snap.displayName;
}
this.#agentHasTranscript.set(snap.id, snap.hasSessionFile);
}
}
#clearAgentMirror(): void {
for (const ref of this.agentRegistry.list()) {
this.agentRegistry.unregister(ref.id);
}
this.#agentHasTranscript.clear();
}
/** Resolve every in-flight transcript request with null (resolvers clear their own timers). */
#flushPendingTranscripts(): void {
for (const resolve of this.#pendingTranscripts.values()) {
resolve(null);
}
this.#pendingTranscripts.clear();
}
/**
* Surface a host `ui-request` (ask select/editor) through the local
* hook-dialog seam. The dialog settles on user submit/cancel — both send a
* `ui-response` (cancel carries `value: undefined`, mirroring the web
* client's Cancel button) — or when {@link #endUiRequest} aborts it because
* the host settled the request elsewhere; that path must NOT respond.
*/
#presentUiRequest(request: CollabUiRequest): void {
// The host only targets writable peers; drop defensively on a read-only link.
if (this.#readOnly || this.#pendingUiRequests.has(request.reqId)) return;
const abort = new AbortController();
this.#pendingUiRequests.set(request.reqId, abort);
const dialog =
request.kind === "select"
? this.#ctx.showHookSelector(request.title, request.options, {
signal: abort.signal,
initialIndex: request.initialIndex,
selectionMarker: request.selectionMarker,
checkedIndices: request.checkedIndices,
markableCount: request.markableCount,
helpText: request.helpText,
})
: this.#ctx.showHookEditor(request.title, request.prefill, { signal: abort.signal });
dialog
.then(value => {
// Identity check: only the presentation that still owns the reqId
// may respond. An abort from #endUiRequest / #clearUiRequests
// removes (or replaces, on resync replay) the entry before this
// microtask runs, so a dismissed dialog stays silent.
if (this.#pendingUiRequests.get(request.reqId) !== abort) return;
this.#pendingUiRequests.delete(request.reqId);
this.#socket?.send({ t: "ui-response", reqId: request.reqId, value });
})
.catch(err => {
if (this.#pendingUiRequests.get(request.reqId) === abort) {
this.#pendingUiRequests.delete(request.reqId);
}
logger.warn("collab guest ui-request presentation failed", {
reqId: request.reqId,
error: String(err),
});
});
}
/** Host settled the request (answered elsewhere or aborted): dismiss without responding. */
#endUiRequest(reqId: number): void {
const abort = this.#pendingUiRequests.get(reqId);
if (!abort) return;
this.#pendingUiRequests.delete(reqId);
abort.abort();
}
/**
* Dismiss every locally presented `ui-request` without responding: on
* resync the host replays the ones still pending, and on leave they are no
* longer ours to answer. Queued dialogs abort before the presented one
* (reverse insertion order) so settling the active dialog cannot flash the
* next queued one onto the surface first.
*/
#clearUiRequests(): void {
if (this.#pendingUiRequests.size === 0) return;
const aborts = [...this.#pendingUiRequests.values()];
this.#pendingUiRequests.clear();
for (const abort of aborts.reverse()) abort.abort();
}
#clearTransientUi(): void {
this.#clearUiRequests();
clearGuestTransientStatus(this.#ctx);
this.#ctx.pendingShellContainer.clear();
this.#ctx.pendingMessagesContainer.clear();
this.#ctx.compactionQueuedMessages = [];
this.#ctx.streamingComponent = undefined;
this.#ctx.streamingMessage = undefined;
this.#ctx.pendingTools.clear();
if (this.#ctx.loadingAnimation) {
this.#ctx.loadingAnimation.stop();
this.#ctx.loadingAnimation = undefined;
}
}
#restoreAfterDisconnect(): void {
void this.#restoreLocalSession().catch(err => {
logger.warn("Collab local restoration failed", { error: String(err) });
this.#ctx.showError(`Failed to restore local session: ${err instanceof Error ? err.message : String(err)}`);
});
}
#restoreLocalSession(): Promise {
if (this.#restoration) return this.#restoration;
this.#left = true;
this.#joinReject?.(new Error("Collab join cancelled"));
this.#clearWelcomeTimer();
this.#clearSnapshotProgressTimer();
this.#pendingSnapshot = null;
this.#socket?.close();
this.#socket = null;
// The host's terminal `agent_end` may never arrive (leave mid-run,
// disconnect, `bye`): unlatch the pane before the local session takes
// over so extensions do not stay stuck on the host's last `agent_start`.
this.#reconcileLifecycle(false);
this.#restoration = this.#runRestoreLocalSession();
this.#ctx.collabController?.resumeAfterGuest(this.#restoration);
return this.#restoration;
}
async #runRestoreLocalSession(): Promise {
// An already-running switch cannot be cancelled halfway through. Drain
// it before rollback; no queued frame may reactivate the replica later.
await this.#applyChain;
try {
if (this.#replicaActivated) await this.#resumeLocalSession();
} finally {
this.#replicaLease?.();
this.#replicaLease = undefined;
}
if (this.#ctx.collabGuest !== this) return false;
this.#ctx.setCollabGuest(undefined);
this.#ctx.syncRunningSubagentBadge();
return this.#replicaActivated;
}
async #resumeLocalSession(): Promise {
this.#ctx.statusLine.setCollabStatus(null);
this.#flushPendingTranscripts();
// A pending coalesced mirror message_update must not flush after leave.
this.#ctx.eventController.resetTranscriptAnchors();
this.#clearAgentMirror();
this.#ctx.syncRunningSubagentBadge();
this.#ctx.resetObserverRegistry();
this.#clearTransientUi();
// Replica file stays on disk: it is a valid session file outside the
// sessions dir, so it never shows up in /resume but remains readable.
if (this.#returnSessionFile) {
await this.#ctx.handleResumeSession(this.#returnSessionFile);
// The TUI resume wrapper returns void even when a hook or settings
// flush cancels it. Only the actual local file can release ownership.
if (this.#ctx.sessionManager.getSessionFile() !== this.#returnSessionFile) {
throw new Error("Local session restoration was cancelled");
}
return;
}
if ((await this.#ctx.session.newSession()) === false) {
throw new Error("Local session restoration was cancelled");
}
setSessionTerminalTitle(this.#ctx.sessionManager.getSessionName(), this.#ctx.sessionManager.getCwd());
this.#ctx.statusLine.invalidate();
this.#ctx.statusLine.resetActiveTime();
this.#ctx.ui.requestRender();
this.#ctx.updateEditorBorderColor();
await this.#ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.#ctx.reloadTodos();
this.#ctx.ui.requestRender(true, { clearScrollback: true });
}
#updateStatusSegment(): void {
this.#ctx.statusLine.setCollabStatus({
role: "guest",
participantCount: this.state?.participants.length ?? 1,
stateOverride: this.state,
});
}
}