Skip to content
Merged
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: 2 additions & 1 deletion CONCEPTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ Shared domain vocabulary for the orchid project. This file defines terms used ac

- **Live Delta Event** — A typed incremental update from a subagent run (`SubagentDeltaEvent`: `spawned`, `text_delta`, `thinking_delta`, `tool_start`, `tool_args_delta`, `tool_result`, `usage`, `terminal`), replacing full-projection broadcasts. Every delta carries `sessionId`, `subagentId`, `runId`, `sequence`, and `sessionRevision`; deltas are batched into one `SubagentEvent` envelope per IPC flush.
- **Session Revision** — A per-session monotonic counter stamped on every subagent live event and snapshot. The renderer uses it to reject stale snapshots and as the floor when reseeding after hydration-buffer overflow.
- **Durable Handoff** — The transfer of subagent output from ephemeral live state to the persisted `SubagentRecord`. On the live-event path the renderer receives the durable record exactly twice — as a seed at spawn and authoritatively at terminal settlement — never per delta. Snapshots, reseeding after hydration-buffer overflow, and lazy hydration may additionally deliver the record outside that path.
- **Durable Handoff** — The transfer of subagent output from ephemeral live state to the persisted `SubagentRecord`. On the live-event path the renderer receives a lightweight `SubagentSummary` at spawn and terminal settlement, never a historical transcript per delta. Summary snapshots and reseeding keep list state current; the full durable record crosses IPC only when its row is selected for transcript detail.
- **Subagent Summary** — The bounded list-row representation of a subagent: identity, role, task, lifecycle timestamps/status, parent-chain attribution, and pre-aggregated usage. It deliberately excludes the durable chain, result, and error transcript payloads.
- **Queued State** — A first-class runtime state (`queued`) for a subagent spawn or resume that exceeds the admission limits (`subagents.max_active_global`, `subagents.max_active_per_session`). Queued records park in a bounded FIFO queue (`subagents.max_queued`) and are admitted on terminal transitions with per-session round-robin fairness. The state is ephemeral like `pending`: visible end-to-end (runtime, IPC, delegate result, UI) but never persisted for fresh spawns — a durable row is written only at admission, and a crash loses queued work. Exception: a resume-queued record (one parked by `follow_up_subagent`) keeps its existing durable row via the runtime `_resumeQueued` marker, so the reopened chain and follow-up message survive a crash while queued; cancelling a resume-queued record persists the interrupted state through the normal terminal-wave path instead of being evicted in place.
- **Closed Subagent** — A terminal subagent marked hidden from the dynamic system prompt (`closed` flag, persisted with the durable row) without deleting its session record, chain, or UI entry. Closing frees prompt space once the result is incorporated; closed records cannot be resumed by `follow_up_subagent`.
- **Lazy Hydration** — On-demand materialization of a durable subagent record back into the runtime manager: evicted lean summaries (chain emptied by retention) and records persisted before the current app launch are rebuilt from the session's stored `subagentChains` when a lifecycle tool targets them. The stored row stays the authoritative complete copy; hydration untracks the retention FIFO and resets the persisted-revision entry so re-materialized records are neither deleted mid-use nor skipped by revision-gated checkpoints.
Expand Down
11 changes: 7 additions & 4 deletions electron/src/main/agents/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type { SubagentRecord as DomainSubagentRecord } from '../../shared/types/
import {
SubagentDeltaEventType,
SubagentStatus,
summarizeSubagentRecord,
type SubagentDeltaEvent,
type SubagentLiveProjection,
type SubagentTerminalState,
Expand Down Expand Up @@ -420,7 +421,7 @@ export class SubagentManager {
this._notify();
this._emitDelta(record, {
type: SubagentDeltaEventType.SPAWNED,
record: this.toDomainRecord(record, { includeLiveTail: false }),
record: summarizeSubagentRecord(this.toDomainRecord(record, { includeLiveTail: false })),
usage: record.usage,
});

Expand Down Expand Up @@ -518,7 +519,7 @@ export class SubagentManager {
this._notify();
this._emitDelta(record, {
type: SubagentDeltaEventType.SPAWNED,
record: this.toDomainRecord(record, { includeLiveTail: false }),
record: summarizeSubagentRecord(this.toDomainRecord(record, { includeLiveTail: false })),
usage: record.usage,
});
if (this._runner) {
Expand All @@ -529,7 +530,7 @@ export class SubagentManager {
this._notify();
this._emitDelta(record, {
type: SubagentDeltaEventType.SPAWNED,
record: this.toDomainRecord(record, { includeLiveTail: false }),
record: summarizeSubagentRecord(this.toDomainRecord(record, { includeLiveTail: false })),
usage: record.usage,
});
}
Expand Down Expand Up @@ -1518,7 +1519,9 @@ export class SubagentManager {
result: record.result,
error: record.error,
usage: record.usage,
terminalRecord: () => this.toDomainRecord(record, { includeLiveTail: true }),
terminalRecord: () => summarizeSubagentRecord(
this.toDomainRecord(record, { includeLiveTail: true }),
),
});
// A terminal subagent's owned background commands die with it (R9); the
// scope id is the record id (see `_startRun`). The scope's foreground
Expand Down
4 changes: 2 additions & 2 deletions electron/src/main/agents/subagent-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ export interface SubagentDeltaBatcherOptions {
* thinking appends per flush, caps each flush at a global event count and byte
* budget, and defers (never drops) overflowing non-terminal deltas to the next
* flush in order. `spawned`/`terminal` are budget-exempt and always flush.
* Delivers one `SubagentEvent` envelope per eligible session per flush; records
* ride only the `spawned`/`terminal` deltas the manager already built.
* Delivers one `SubagentEvent` envelope per eligible session per flush;
* lightweight summaries ride only the `spawned`/`terminal` deltas.
*/
export function createSubagentDeltaBatcher(
deliver: (envelope: SubagentEvent) => void,
Expand Down
27 changes: 23 additions & 4 deletions electron/src/main/ipc/chat/events.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { webContents as electronWebContents, type WebContents } from 'electron';
import { IPC_CHANNELS } from '../../../shared/types/ipc';
import { IPC_CHANNELS, type SessionUpdatedEvent } from '../../../shared/types/ipc';
import type { Session } from '../../../shared/types/session';
import { getSessionManager } from '../../session/singleton';
import type { ActiveAgent, ChatStatePayload } from './state';

Expand All @@ -16,7 +17,7 @@ export function sendSessionEvent(
source: WebContents | null,
sessionId: string,
channel: string,
payload: Record<string, unknown>,
payload: object,
): void {
const recipients = new Map<number, WebContents>();
const addIfSelected = (candidate: WebContents): void => {
Expand Down Expand Up @@ -108,12 +109,30 @@ export function sendChatState(
sendTurnEvent(webContents, active, IPC_CHANNELS.CHAT_STATE, payload);
}

/** Build the bounded renderer patch for the chain changed by this write. */
export function buildSessionUpdatedEvent(
session: Session,
chainId: string | null = session.activeChainId,
): SessionUpdatedEvent | null {
const chain = chainId
? session.chains.find((candidate) => candidate.id === chainId)
: session.chains.at(-1);
if (!chain) return null;
return {
sessionId: session.id,
chain,
activeChainId: session.activeChainId,
updatedAt: session.updatedAt,
};
}

/** Notify renderer of live session (multi-chain) state after startChain. */
export function emitSessionUpdated(webContents: WebContents, sessionId: string): void {
try {
const session = getSessionManager().getSession(sessionId);
if (session) {
sendSessionEvent(webContents, sessionId, IPC_CHANNELS.SESSION_UPDATED, { session });
const update = session ? buildSessionUpdatedEvent(session) : null;
if (update) {
sendSessionEvent(webContents, sessionId, IPC_CHANNELS.SESSION_UPDATED, update);
}
} catch {
// non-fatal
Expand Down
12 changes: 7 additions & 5 deletions electron/src/main/ipc/chat/persist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
makeThinkingMessage,
} from '../../llm/message-factories';
import { activeAgents, pendingCheckpoints, type ActiveAgent } from './state';
import { sendSessionEvent, webContentsForWindowId } from './events';
import { buildSessionUpdatedEvent, sendSessionEvent, webContentsForWindowId } from './events';
import { textSegmentIdAtOffset } from './snapshot';

export function attachUsageToLatestAssistant(messages: Message[], usage: Usage): boolean {
Expand Down Expand Up @@ -151,12 +151,13 @@ export function checkpointActiveTurn(agent: ActiveAgent, context: AgentContext):
entry?.messages ?? messages,
sessionId,
);
if (updated) {
const update = updated ? buildSessionUpdatedEvent(updated) : null;
if (update) {
sendSessionEvent(
webContentsForWindowId(active.windowId),
sessionId,
IPC_CHANNELS.SESSION_UPDATED,
{ session: updated },
update,
);
}
} catch (err) {
Expand Down Expand Up @@ -203,8 +204,9 @@ export function persistTurnConversation(
agentType: agent.type,
agentTier: agent.tier,
}, sessionId);
if (updated && webContents) {
sendSessionEvent(webContents, sessionId, IPC_CHANNELS.SESSION_UPDATED, { session: updated });
const update = updated ? buildSessionUpdatedEvent(updated, null) : null;
if (update && webContents) {
sendSessionEvent(webContents, sessionId, IPC_CHANNELS.SESSION_UPDATED, update);
}
} catch (err) {
console.debug('Failed to persist chat chain (non-fatal):', err);
Expand Down
5 changes: 5 additions & 0 deletions electron/src/main/ipc/payload-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ export const subagentSnapshotSchema = z.object({
sessionId: z.string().uuid(),
}).strict();

export const subagentDetailSchema = z.object({
sessionId: z.string().uuid(),
subagentId: z.string().min(1),
}).strict();

// ── Ask Question ────────────────────────────────────────────────────────────

export const askQuestionAnswerSchema = z.object({
Expand Down
16 changes: 11 additions & 5 deletions electron/src/main/ipc/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/
import { BrowserWindow, dialog, ipcMain } from 'electron';
import { IPC_CHANNELS } from '../../shared/types/ipc';
import { flattenSessionMessages } from '../../shared/types/session';
import { flattenSessionMessages, sessionForRenderer } from '../../shared/types/session';
import type { ModelSelection } from '../../shared/types/provider';
import {
getSessionManager,
Expand Down Expand Up @@ -76,7 +76,7 @@ export {
resolveWindowWorkspace,
};

export { flattenSessionMessages };
export { flattenSessionMessages, sessionForRenderer };

export { takeDraftReasoningOverride } from '../session/draft-reasoning';

Expand Down Expand Up @@ -234,7 +234,8 @@ export function registerSessionIPC(): void {

// Read-only peek (todos / subagents refresh) — do not switch or reseed.
if (!activate) {
return manager.load(id);
const session = manager.load(id);
return session ? sessionForRenderer(session) : null;
}

const releasedDraftCwd = getDraftCwd(windowId);
Expand Down Expand Up @@ -274,7 +275,7 @@ export function registerSessionIPC(): void {
const workspace = resolveWindowWorkspace(windowId);

emitWorkspaceChanged(event.sender, workspace);
return session;
return session ? sessionForRenderer(session) : null;
});

// session:open — activate a session and return its full view payload in one
Expand Down Expand Up @@ -324,7 +325,12 @@ export function registerSessionIPC(): void {
const { getLiveChatSnapshot } = await import('./chat.js');
const live = getLiveChatSnapshot(id);

return { session, messages, live, workspace };
return {
session: session ? sessionForRenderer(session) : null,
messages,
live,
workspace,
};
});

// session:create — eagerly create + activate a session (writes to disk).
Expand Down
42 changes: 39 additions & 3 deletions electron/src/main/ipc/subagents.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
/** Session-affine subagent snapshot and live projection IPC. */
import { ipcMain } from 'electron';
import { IPC_CHANNELS, type SubagentSnapshot } from '../../shared/types/ipc';
import {
IPC_CHANNELS,
type SubagentDetailResult,
type SubagentSnapshot,
} from '../../shared/types/ipc';
import type { SubagentRecord as DomainSubagentRecord } from '../../shared/types/subagent';
import { summarizeSubagentRecord } from '../../shared/types/subagent';
import { getSubagentManager } from '../tools';
import { getSessionManager } from '../session/singleton';
import { subagentSnapshotSchema } from './payload-schemas';
import { subagentDetailSchema, subagentSnapshotSchema } from './payload-schemas';
import { flushSubagentDeltas } from '../agents/subagent-events';

// Compatibility exports for existing IPC consumers. Event ownership lives in
Expand All @@ -30,6 +35,16 @@ export function mergeSubagentRecords(stored: readonly DomainSubagentRecord[], ru
return [...merged.values()];
}

export function selectSubagentDetailRecord(
subagentId: string,
stored: readonly DomainSubagentRecord[],
runtime: DomainSubagentRecord | null,
): DomainSubagentRecord | null {
return (runtime?.id === subagentId ? runtime : null)
?? stored.find((record) => record.id === subagentId)
?? null;
}

export function createSubagentSnapshot(sessionId: string): SubagentSnapshot {
const manager = getSubagentManager();
const session = getSessionManager().getSession(sessionId);
Expand All @@ -44,11 +59,26 @@ export function createSubagentSnapshot(sessionId: string): SubagentSnapshot {
return {
sessionId,
sessionRevision: manager.getSessionRevision(sessionId),
records,
records: records.map(summarizeSubagentRecord),
live: manager.getLiveProjections(sessionId),
};
}

/** Materialize only the transcript explicitly selected in the renderer. */
export function createSubagentDetail(
sessionId: string,
subagentId: string,
): SubagentDetailResult {
const manager = getSubagentManager();
const candidate = manager.getRecord(subagentId);
const runtime = candidate?.sessionId === sessionId && !manager.isSummary(candidate.id)
? manager.toDomainRecord(candidate, { includeLiveTail: true })
: null;
const stored = getSessionManager().getSession(sessionId)?.subagentChains ?? [];
const record = selectSubagentDetailRecord(subagentId, stored, runtime);
return { sessionId, subagentId, record };
}

let wired = false;

export function registerSubagentIPC(): void {
Expand All @@ -59,11 +89,17 @@ export function registerSubagentIPC(): void {
if (!parsed.success) throw new Error(`Invalid subagent snapshot request: ${parsed.error.message}`);
return createSubagentSnapshot(parsed.data.sessionId);
});
ipcMain.handle(IPC_CHANNELS.SUBAGENTS_DETAIL, (_event, raw: unknown) => {
const parsed = subagentDetailSchema.safeParse(raw);
if (!parsed.success) throw new Error(`Invalid subagent detail request: ${parsed.error.message}`);
return createSubagentDetail(parsed.data.sessionId, parsed.data.subagentId);
});
}

export function unregisterSubagentIPC(): void {
if (!wired) return;
wired = false;
ipcMain.removeHandler(IPC_CHANNELS.SUBAGENTS_SNAPSHOT);
ipcMain.removeHandler(IPC_CHANNELS.SUBAGENTS_DETAIL);
flushSubagentDeltas();
}
9 changes: 8 additions & 1 deletion electron/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ import type {
BgCommandChangedEvent,
SubagentSnapshotRequest,
SubagentSnapshot,
SubagentDetailRequest,
SubagentDetailResult,
SubagentEvent,
AskQuestionAnswerMessage,
AskQuestionCancelMessage,
Expand Down Expand Up @@ -129,6 +131,7 @@ import {
chatToolCallUpdateEventSchema,
sessionRenamedEventSchema,
sessionCreatedEventSchema,
sessionUpdatedEventSchema,
sessionWorkspaceChangedEventSchema,
sessionTodosChangedEventSchema,
sessionActivityChangedEventSchema,
Expand All @@ -149,6 +152,7 @@ import {
sessionServiceTierConfigResultSchema,
chatSessionSnapshotSchema,
subagentSnapshotSchema,
subagentDetailResultSchema,
subagentEventSchema,
subagentDeltaEventSchema,
startupSnapshotSchema,
Expand Down Expand Up @@ -184,6 +188,7 @@ const INVOKE_RESULT_SCHEMAS: Partial<Record<string, z.ZodTypeAny>> = {
[IPC_CHANNELS.CHAT_SEND]: chatSendResultSchema,
[IPC_CHANNELS.CHAT_SNAPSHOT]: chatSessionSnapshotSchema,
[IPC_CHANNELS.SUBAGENTS_SNAPSHOT]: subagentSnapshotSchema,
[IPC_CHANNELS.SUBAGENTS_DETAIL]: subagentDetailResultSchema,
[IPC_CHANNELS.TOOL_EXECUTE]: toolExecuteResultSchema,
[IPC_CHANNELS.BG_CMD_SNAPSHOT]: bgCommandSnapshotResultSchema,
[IPC_CHANNELS.BG_CMD_LIST]: bgCommandListResultSchema,
Expand Down Expand Up @@ -506,7 +511,7 @@ const orchidAPI: OrchidAPI = {
onParsed(IPC_CHANNELS.SESSION_CREATED, sessionCreatedEventSchema, callback),

onUpdated: (callback: (event: SessionUpdatedEvent) => void) =>
onParsed(IPC_CHANNELS.SESSION_UPDATED, sessionCreatedEventSchema, callback),
onParsed(IPC_CHANNELS.SESSION_UPDATED, sessionUpdatedEventSchema, callback),

onWorkspaceChanged: (callback: (event: SessionWorkspaceChangedEvent) => void) =>
onParsed(IPC_CHANNELS.SESSION_WORKSPACE_CHANGED, sessionWorkspaceChangedEventSchema, callback),
Expand Down Expand Up @@ -538,6 +543,8 @@ const orchidAPI: OrchidAPI = {
subagents: {
snapshot: (request: SubagentSnapshotRequest) =>
invoke<SubagentSnapshot>(IPC_CHANNELS.SUBAGENTS_SNAPSHOT, request),
detail: (request: SubagentDetailRequest) =>
invoke<SubagentDetailResult>(IPC_CHANNELS.SUBAGENTS_DETAIL, request),
onEvent: (callback: (event: SubagentEvent) => void) =>
onSubagentEvent(callback),
},
Expand Down
11 changes: 4 additions & 7 deletions electron/src/renderer/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -341,15 +341,13 @@ export function ChatView({ isVisible = true, bootstrapConfig = null, onNotify, a
(loadedSession: Session | null) => {
if (!loadedSession) {
chat.setMessages([]);
subagents.applyFromSession([]);
todos.applyFromSession([]);
return;
}
chat.setMessages(flattenSessionMessages(loadedSession));
subagents.applyFromSession(loadedSession.subagentChains);
todos.applyFromSession(loadedSession.todoStore.tasks);
},
[chat.setMessages, subagents.applyFromSession, todos.applyFromSession],
[chat.setMessages, todos.applyFromSession],
);

const handleSessionSelect = useCallback(
Expand Down Expand Up @@ -387,17 +385,16 @@ export function ChatView({ isVisible = true, bootstrapConfig = null, onNotify, a

setDraftTabVisible(false);
messageQueue.clearQueue();
// Commit once: sidebar lists from the session, chat (messages + live) via
// hydrate. hydrateSnapshot owns the single message replace (no double set).
subagents.applyFromSession(result.session.subagentChains);
// Commit once: subagent summaries hydrate independently; chat messages
// and live state hydrate here without a duplicate message replace.
todos.applyFromSession(result.session.todoStore.tasks);
chat.hydrateSnapshot({
sessionId: result.session.id,
messages: result.messages,
live: result.live,
});
},
[session, chat.beginSessionSwitch, chat.hydrateSnapshot, subagents.applyFromSession, todos.applyFromSession, draftTabVisible, messageQueue.clearQueue],
[session, chat.beginSessionSwitch, chat.hydrateSnapshot, todos.applyFromSession, draftTabVisible, messageQueue.clearQueue],
);

useEffect(() => {
Expand Down
Loading
Loading