- Move lifecycle end event after finalizeSubprocessOutput() so
submit_result({status:'aborted'}) is correctly reflected in the
observer instead of showing completed/failed (P1)
- Reset observer registry on session resume and /new command so
stale subagent sessions from prior conversations are cleared (P2)
- Cache parsed JSONL transcript incrementally: track byte offset,
only read and parse new bytes on each refresh instead of reparsing
the entire file, avoiding render loop blocking on large sessions (P2)
147 lines
4.1 KiB
TypeScript
147 lines
4.1 KiB
TypeScript
import type { AgentProgress, SubagentLifecyclePayload, SubagentProgressPayload } from "../task";
|
|
import { TASK_SUBAGENT_LIFECYCLE_CHANNEL, TASK_SUBAGENT_PROGRESS_CHANNEL } from "../task";
|
|
import type { EventBus } from "../utils/event-bus";
|
|
|
|
export interface ObservableSession {
|
|
id: string;
|
|
kind: "main" | "subagent";
|
|
label: string;
|
|
agent?: string;
|
|
description?: string;
|
|
status: "active" | "completed" | "failed" | "aborted";
|
|
sessionFile?: string;
|
|
lastUpdate: number;
|
|
/** Latest progress snapshot from the subagent executor */
|
|
progress?: AgentProgress;
|
|
}
|
|
|
|
const STATUS_MAP: Record<string, ObservableSession["status"]> = {
|
|
started: "active",
|
|
completed: "completed",
|
|
failed: "failed",
|
|
aborted: "aborted",
|
|
};
|
|
|
|
export class SessionObserverRegistry {
|
|
#sessions = new Map<string, ObservableSession>();
|
|
#listeners = new Set<() => void>();
|
|
#eventBusUnsubscribers: Array<() => void> = [];
|
|
|
|
/** Add a change listener. Returns unsubscribe function. */
|
|
onChange(cb: () => void): () => void {
|
|
this.#listeners.add(cb);
|
|
return () => this.#listeners.delete(cb);
|
|
}
|
|
|
|
#notifyListeners(): void {
|
|
for (const cb of this.#listeners) cb();
|
|
}
|
|
|
|
setMainSession(sessionFile?: string): void {
|
|
const existing = this.#sessions.get("main");
|
|
this.#sessions.set("main", {
|
|
id: "main",
|
|
kind: "main",
|
|
label: "Main Session",
|
|
status: "active",
|
|
sessionFile: sessionFile ?? existing?.sessionFile,
|
|
lastUpdate: Date.now(),
|
|
});
|
|
this.#notifyListeners();
|
|
}
|
|
|
|
getSessions(): ObservableSession[] {
|
|
const sessions = [...this.#sessions.values()];
|
|
sessions.sort((a, b) => {
|
|
if (a.kind === "main") return -1;
|
|
if (b.kind === "main") return 1;
|
|
return a.lastUpdate - b.lastUpdate;
|
|
});
|
|
return sessions;
|
|
}
|
|
|
|
getActiveSubagentCount(): number {
|
|
let count = 0;
|
|
for (const s of this.#sessions.values()) {
|
|
if (s.kind === "subagent" && s.status === "active") count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/** Clear all tracked sessions (e.g. on session switch). Keeps EventBus subscriptions and listeners. */
|
|
resetSessions(): void {
|
|
this.#sessions.clear();
|
|
this.#notifyListeners();
|
|
}
|
|
|
|
dispose(): void {
|
|
for (const unsub of this.#eventBusUnsubscribers) unsub();
|
|
this.#eventBusUnsubscribers = [];
|
|
this.#sessions.clear();
|
|
this.#listeners.clear();
|
|
}
|
|
|
|
subscribeToEventBus(eventBus: EventBus): void {
|
|
// Dispose previous EventBus subscriptions if called again
|
|
for (const unsub of this.#eventBusUnsubscribers) unsub();
|
|
this.#eventBusUnsubscribers = [];
|
|
|
|
this.#eventBusUnsubscribers.push(
|
|
eventBus.on(TASK_SUBAGENT_LIFECYCLE_CHANNEL, data => {
|
|
const payload = data as SubagentLifecyclePayload;
|
|
const status = STATUS_MAP[payload.status];
|
|
if (!status) return;
|
|
|
|
const existing = this.#sessions.get(payload.id);
|
|
if (existing) {
|
|
existing.status = status;
|
|
existing.lastUpdate = Date.now();
|
|
if (payload.description) existing.description = payload.description;
|
|
if (payload.sessionFile) existing.sessionFile = payload.sessionFile;
|
|
} else {
|
|
this.#sessions.set(payload.id, {
|
|
id: payload.id,
|
|
kind: "subagent",
|
|
label: payload.description ?? `Subagent #${payload.index}`,
|
|
agent: payload.agent,
|
|
description: payload.description,
|
|
status,
|
|
sessionFile: payload.sessionFile,
|
|
lastUpdate: Date.now(),
|
|
});
|
|
}
|
|
this.#notifyListeners();
|
|
}),
|
|
);
|
|
|
|
this.#eventBusUnsubscribers.push(
|
|
eventBus.on(TASK_SUBAGENT_PROGRESS_CHANNEL, data => {
|
|
const payload = data as SubagentProgressPayload;
|
|
const progress = payload.progress;
|
|
const id = progress.id;
|
|
const existing = this.#sessions.get(id);
|
|
|
|
if (existing) {
|
|
existing.lastUpdate = Date.now();
|
|
existing.progress = progress;
|
|
if (progress.description) existing.description = progress.description;
|
|
if (payload.sessionFile) existing.sessionFile = payload.sessionFile;
|
|
} else {
|
|
this.#sessions.set(id, {
|
|
id,
|
|
kind: "subagent",
|
|
label: progress.description ?? `Subagent #${payload.index}`,
|
|
agent: payload.agent,
|
|
description: progress.description,
|
|
status: "active",
|
|
sessionFile: payload.sessionFile,
|
|
lastUpdate: Date.now(),
|
|
progress,
|
|
});
|
|
}
|
|
this.#notifyListeners();
|
|
}),
|
|
);
|
|
}
|
|
}
|