mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
* feat(server): let clients create internal agents The daemon's own branch-name and commit-message generators run as internal agents: never persisted, listed, flagged, announced, or seen by plugin lifecycle hooks. Nothing outside the daemon could ask for one, because the wire config schema strips `internal` and the agent.create transform re-pins it. A plugin that runs a one-turn helper per event therefore left an archived session in History every time. Add an optional `internal` flag to `create_agent_request` (inherited by `agent.create.request`), copy it onto the config in the session's create path, and skip provider session persistence for it like the generators do. The client SDK forwards the flag on both creation paths and gates it once on `server_info.features.internalAgents`. Two daemon paths assumed every agent is public and had to follow: `resolveAgentIdentifier` now accepts a live agent by exact id, since the id set it matched against came from listings that exclude internal agents and left `wait_for_finish` answering "Agent not found"; and archiving an internal agent now closes it without writing the record that `registry.applySnapshot` used to leave behind, so `autoArchive` does not accumulate hidden files on disk. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(server): keep internal agents whole through failure, archive, and wait Review follow-ups for internal agents: - A create that fails after the runtime is registered now closes the internal agent before rethrowing, so a hidden runtime cannot outlive the failed request. Public agents keep today's behaviour. - Archiving an internal agent cascades to attached children like any parent. cascadeArchiveChildren takes the parent record instead of reading it back from storage, where an internal agent never is. - The archived snapshot and last message of an internal agent stay readable by exact id for ten minutes, so create-then-waitForFinish returns the finished turn whether the wait lands before or after an auto-archive. Storage plays that role for public agents. - The internal-agent e2e scenarios live in the existing create_agent_request lifecycle suite rather than a new file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(server): cascade to live internal children and keep a failed internal create's worktree until it closes Internal children never reach storage, so the parent archive cascade scans live agents for them as well. A failed internal create now clears the created id only after the runtime is archived, so worktree cleanup cannot delete the directory under a runtime that would not close. Stored agent payloads carry lastError, so a retained failed helper reports its error through wait_for_finish instead of the generic message. * feat(cli,mcp): expose internal agents and document --no-wait paseo run gains --internal and create_agent gains internal, both forwarded to the create request. --no-wait becomes the documented spelling for returning immediately, matching paseo send; --background and --detach stay as hidden aliases under COMPAT tags. * test: follow the --no-wait rename and stub getRetiredInternalAgent * feat: list internal agents on request and add internal workspaces Internal agents stay hidden by default, but a caller can now ask for them: `fetch_agents_request` takes `filter.includeInternal`, snapshots carry `internal: true`, and `paseo ls --internal` shows them in an INTERNAL column. The app never passes the flag. Workspaces get the same flag. `workspace.create.request` with `internal: true` persists a hidden workspace: listings, update subscriptions, sync reads, empty-project counting, open-by-path, MCP `list_workspaces`, and plugin workspace hooks all skip it unless the caller opts in with `filter.includeInternal`. Every agent created inside an internal workspace is internal and unpersisted; AgentManager applies that from the workspace id before the plugin hook so creators cannot opt out. Archive and shutdown enumerate internal agents so nothing inside an internal workspace outlives it. Surfaces: `paseo workspace create --internal`, `paseo workspace ls --internal`, `paseo run --internal --new-workspace`, MCP `create_workspace({ internal })`, SDK `workspaces.create({ internal })` and `list({ filter: { includeInternal } })`. Clients gate on `features.internalWorkspaces`. Co-Authored-By: Claude <noreply@anthropic.com> * feat(workspaces): add background visibility with inherited creation defaults * fix(cli): resolve workspace callers on the selected daemon * test(workspaces): keep caller integration coverage in server suite * test(cli): exercise workspace callers through CLI integration harness * test(mcp): remove obsolete execution flags from browser fixtures --------- Co-authored-by: marvin-ambrozio <marvin@gustavo.eng.br> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
1908 lines
60 KiB
TypeScript
1908 lines
60 KiB
TypeScript
import equal from "fast-deep-equal";
|
|
import { create } from "zustand";
|
|
import { subscribeWithSelector } from "zustand/middleware";
|
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
|
import type { ViewedTimelineOwner } from "@/timeline/viewed-timeline-sync";
|
|
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
|
import {
|
|
appendSubmittedUserMessage,
|
|
handoffCreatedAgentUserMessageToStream,
|
|
removeSubmittedUserMessage,
|
|
type StreamItem,
|
|
type TodoEntry,
|
|
type UserMessageItem,
|
|
} from "@/types/stream";
|
|
import {
|
|
acceptMessageSubmission,
|
|
beginMessageSubmission,
|
|
getActiveMessageSubmissions,
|
|
observeMessageSubmissionCanonical,
|
|
rejectMessageSubmission,
|
|
type MessageSubmissionRecord,
|
|
type MessageSubmissionRejectionOutcome,
|
|
} from "@/composer/submission/model";
|
|
import type { PendingPermission } from "@/types/shared";
|
|
import type { ComposerAttachment } from "@/attachments/types";
|
|
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
|
|
import type {
|
|
AgentPermissionRequest,
|
|
AgentFeature,
|
|
AgentProvider,
|
|
AgentMode,
|
|
AgentCapabilityFlags,
|
|
AgentUsage,
|
|
AgentPersistenceHandle,
|
|
} from "@getpaseo/protocol/agent-types";
|
|
import type {
|
|
ServerInfoStatusPayload,
|
|
ProjectPlacementPayload,
|
|
ServerCapabilities,
|
|
WorkspaceDescriptorPayload,
|
|
WorkspaceProjectDescriptorPayload,
|
|
} from "@getpaseo/protocol/messages";
|
|
import {
|
|
normalizeWorkspaceOpaqueId,
|
|
normalizeWorkspacePath,
|
|
resolveWorkspaceMapKeyByIdentity,
|
|
} from "@/utils/workspace-identity";
|
|
import {
|
|
createAgentLastActivityCoalescer,
|
|
type AgentLastActivityCommitter,
|
|
} from "@/runtime/activity";
|
|
import {
|
|
buildWorkspaceAgentActivityIndex,
|
|
type WorkspaceAgentActivity,
|
|
} from "@/utils/workspace-agent-activity";
|
|
import {
|
|
resolveTurnPresentation,
|
|
TURN_LIVENESS_IDLE,
|
|
type TurnLiveness,
|
|
type TurnPresentation,
|
|
} from "@/timeline/turn-liveness";
|
|
|
|
export interface AgentRuntimeInfo {
|
|
provider: AgentProvider;
|
|
sessionId: string | null;
|
|
model?: string | null;
|
|
modeId?: string | null;
|
|
thinkingOptionId?: string | null;
|
|
extra?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface Agent {
|
|
serverId: string;
|
|
id: string;
|
|
provider: AgentProvider;
|
|
status: AgentLifecycleStatus;
|
|
turn: TurnLiveness;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
lastUserMessageAt: Date | null;
|
|
lastActivityAt: Date;
|
|
capabilities: AgentCapabilityFlags;
|
|
currentModeId: string | null;
|
|
availableModes: AgentMode[];
|
|
pendingPermissions: AgentPermissionRequest[];
|
|
persistence: AgentPersistenceHandle | null;
|
|
runtimeInfo?: AgentRuntimeInfo;
|
|
lastUsage?: AgentUsage;
|
|
lastError?: string | null;
|
|
title: string | null;
|
|
cwd: string;
|
|
workspaceId?: string;
|
|
model: string | null;
|
|
features?: AgentFeature[];
|
|
thinkingOptionId?: string | null;
|
|
requiresAttention?: boolean;
|
|
attentionReason?: "finished" | "error" | "permission" | null;
|
|
attentionTimestamp?: Date | null;
|
|
archivedAt?: Date | null;
|
|
parentAgentId: string | null;
|
|
labels: Record<string, string>;
|
|
projectPlacement?: ProjectPlacementPayload | null;
|
|
}
|
|
|
|
export interface WorkspaceDescriptor {
|
|
id: string;
|
|
projectId: string;
|
|
projectDisplayName: string;
|
|
projectCustomName?: string | null;
|
|
projectCustomIconRevision?: string | null;
|
|
projectRootPath: string;
|
|
workspaceDirectory: string;
|
|
worktreeSlug?: WorkspaceDescriptorPayload["worktreeSlug"];
|
|
projectKind: WorkspaceDescriptorPayload["projectKind"];
|
|
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
|
|
background?: boolean;
|
|
name: string;
|
|
title?: string | null;
|
|
pinnedAt?: string | null;
|
|
labels?: string[];
|
|
status: WorkspaceDescriptorPayload["status"];
|
|
statusEnteredAt: Date | null;
|
|
archivingAt: string | null;
|
|
diffStat: { additions: number; deletions: number } | null;
|
|
scripts: WorkspaceDescriptorPayload["scripts"];
|
|
gitRuntime?: WorkspaceDescriptorPayload["gitRuntime"];
|
|
githubRuntime?: WorkspaceDescriptorPayload["githubRuntime"];
|
|
forge?: WorkspaceDescriptorPayload["forge"];
|
|
project?: ProjectPlacementPayload;
|
|
}
|
|
|
|
export function normalizeWorkspaceDescriptor(
|
|
payload: WorkspaceDescriptorPayload,
|
|
): WorkspaceDescriptor {
|
|
const statusEnteredAtRaw = payload.statusEnteredAt;
|
|
const statusEnteredAt: Date | null =
|
|
typeof statusEnteredAtRaw === "string" && statusEnteredAtRaw.length > 0
|
|
? new Date(statusEnteredAtRaw)
|
|
: null;
|
|
return {
|
|
id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id,
|
|
projectId: payload.projectId,
|
|
projectDisplayName: payload.projectDisplayName,
|
|
projectCustomName: payload.projectCustomName ?? null,
|
|
projectCustomIconRevision: payload.projectCustomIconRevision ?? null,
|
|
projectRootPath: payload.projectRootPath,
|
|
// Canonicalize the workspace directory once, at the store boundary, so every
|
|
// consumer can read workspace.workspaceDirectory directly. Empty means "no
|
|
// usable directory" (older daemons may omit it; the wire field is optional).
|
|
workspaceDirectory: normalizeWorkspacePath(payload.workspaceDirectory) ?? "",
|
|
worktreeSlug: payload.worktreeSlug,
|
|
projectKind: payload.projectKind,
|
|
workspaceKind: payload.workspaceKind,
|
|
background: payload.background ?? false,
|
|
name: payload.name,
|
|
title: payload.title ?? null,
|
|
pinnedAt: payload.pinnedAt ?? null,
|
|
// COMPAT(workspaceLabels): old daemons omit assignments.
|
|
labels: payload.labels ?? [],
|
|
status: payload.status,
|
|
statusEnteredAt,
|
|
archivingAt: payload.archivingAt ?? null,
|
|
diffStat: payload.diffStat ?? null,
|
|
scripts: (payload.scripts ?? []).map((s) => Object.assign({}, s)),
|
|
gitRuntime: payload.gitRuntime,
|
|
githubRuntime: payload.githubRuntime,
|
|
forge: payload.forge,
|
|
project: payload.project,
|
|
};
|
|
}
|
|
|
|
export interface ProjectDescriptor {
|
|
projectId: string;
|
|
projectKey?: string | null;
|
|
projectDisplayName: string;
|
|
projectCustomName: string | null;
|
|
projectCustomIconRevision?: string | null;
|
|
projectIconRevision?: string;
|
|
projectRootPath: string;
|
|
projectKind: WorkspaceDescriptorPayload["projectKind"];
|
|
}
|
|
|
|
export function normalizeProjectDescriptor(
|
|
payload: WorkspaceProjectDescriptorPayload,
|
|
): ProjectDescriptor {
|
|
return {
|
|
projectId: payload.projectId,
|
|
projectKey: payload.projectKey ?? null,
|
|
projectDisplayName: payload.projectDisplayName,
|
|
projectCustomName: payload.projectCustomName ?? null,
|
|
projectCustomIconRevision: payload.projectCustomIconRevision ?? null,
|
|
projectIconRevision: payload.projectIconRevision,
|
|
projectRootPath: payload.projectRootPath,
|
|
projectKind: payload.projectKind,
|
|
};
|
|
}
|
|
|
|
function preserveWorkspaceDescriptorIdentity(
|
|
incoming: WorkspaceDescriptor,
|
|
existing?: WorkspaceDescriptor | null,
|
|
): WorkspaceDescriptor {
|
|
if (existing && equal(existing, incoming)) {
|
|
return existing;
|
|
}
|
|
return incoming;
|
|
}
|
|
|
|
function preserveMapIdentity<Key, Value>(
|
|
existing: Map<Key, Value>,
|
|
incoming: Map<Key, Value>,
|
|
): Map<Key, Value> {
|
|
if (existing === incoming) {
|
|
return existing;
|
|
}
|
|
|
|
const next = new Map<Key, Value>();
|
|
let changed = existing.size !== incoming.size;
|
|
const existingEntries = existing.entries();
|
|
|
|
for (const [key, workspace] of incoming) {
|
|
const existingWorkspace = existing.get(key);
|
|
const nextWorkspace =
|
|
existingWorkspace && equal(existingWorkspace, workspace) ? existingWorkspace : workspace;
|
|
next.set(key, nextWorkspace);
|
|
const existingEntry = existingEntries.next().value;
|
|
if (!existingEntry || existingEntry[0] !== key || existingEntry[1] !== nextWorkspace) {
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
return changed ? next : existing;
|
|
}
|
|
|
|
export type ExplorerEntryKind = "file" | "directory";
|
|
export type ExplorerFileKind = "text" | "image" | "binary";
|
|
export type ExplorerEncoding = "utf-8" | "base64" | "none";
|
|
|
|
export interface ExplorerEntry {
|
|
name: string;
|
|
path: string;
|
|
kind: ExplorerEntryKind;
|
|
size: number;
|
|
modifiedAt: string;
|
|
}
|
|
|
|
export interface ExplorerFile {
|
|
path: string;
|
|
kind: ExplorerFileKind;
|
|
encoding: ExplorerEncoding;
|
|
content?: string;
|
|
// TextDecoder removes a leading UTF-8 BOM; retain this bit so file writes can restore it.
|
|
hasBom: boolean;
|
|
mimeType?: string;
|
|
size: number;
|
|
modifiedAt: string;
|
|
revision?: string;
|
|
}
|
|
|
|
export interface ExplorerDirectory {
|
|
path: string;
|
|
entries: ExplorerEntry[];
|
|
}
|
|
|
|
interface ExplorerRequestState {
|
|
path: string;
|
|
mode: "list" | "file";
|
|
}
|
|
|
|
export interface AgentFileExplorerState {
|
|
directories: Map<string, ExplorerDirectory>;
|
|
files: Map<string, ExplorerFile>;
|
|
isLoading: boolean;
|
|
lastError: string | null;
|
|
pendingRequest: ExplorerRequestState | null;
|
|
currentPath: string;
|
|
history: string[];
|
|
lastVisitedPath: string;
|
|
selectedEntryPath: string | null;
|
|
}
|
|
|
|
export interface DaemonServerInfo {
|
|
serverId: string;
|
|
hostname: string | null;
|
|
version: string | null;
|
|
desktopManaged?: boolean;
|
|
capabilities?: ServerCapabilities;
|
|
features?: ServerInfoStatusPayload["features"];
|
|
}
|
|
|
|
export function toDaemonServerInfo(serverInfo: ServerInfoStatusPayload): DaemonServerInfo {
|
|
return {
|
|
serverId: serverInfo.serverId,
|
|
hostname: serverInfo.hostname ?? null,
|
|
version: serverInfo.version ?? null,
|
|
...(serverInfo.desktopManaged !== undefined
|
|
? { desktopManaged: serverInfo.desktopManaged }
|
|
: {}),
|
|
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
|
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
|
};
|
|
}
|
|
|
|
export interface AgentTimelineCursorState {
|
|
epoch: string;
|
|
startSeq: number;
|
|
endSeq: number;
|
|
retainedRanges?: Array<{
|
|
startSeq: number;
|
|
endSeq: number;
|
|
hasOlder?: boolean;
|
|
}>;
|
|
}
|
|
|
|
export type AgentTimelineState =
|
|
| { status: "cold" }
|
|
| { status: "painted"; items: StreamItem[] }
|
|
| {
|
|
status: "synced";
|
|
items: StreamItem[];
|
|
range: AgentTimelineCursorState | null;
|
|
older: "available" | "none";
|
|
newer: "available" | "none";
|
|
};
|
|
|
|
export function selectAgentTimelineState(
|
|
session: SessionState | undefined,
|
|
agentId: string,
|
|
): AgentTimelineState {
|
|
if (!session) return { status: "cold" };
|
|
const items = session.agentStreamTail.get(agentId) ?? [];
|
|
if (session.agentAuthoritativeHistoryApplied.get(agentId) === true) {
|
|
return {
|
|
status: "synced",
|
|
items,
|
|
range: session.agentTimelineCursor.get(agentId) ?? null,
|
|
older: session.agentTimelineHasOlder.get(agentId) === true ? "available" : "none",
|
|
newer: session.agentTimelineHasNewer.get(agentId) === true ? "available" : "none",
|
|
};
|
|
}
|
|
return items.length > 0 ? { status: "painted", items } : { status: "cold" };
|
|
}
|
|
|
|
export function selectAgentTurnPresentation(
|
|
session: SessionState | undefined,
|
|
agentId: string,
|
|
): TurnPresentation {
|
|
return resolveTurnPresentation(
|
|
session?.agents.get(agentId)?.turn ??
|
|
session?.agentDetails.get(agentId)?.turn ??
|
|
TURN_LIVENESS_IDLE,
|
|
getActiveMessageSubmissions(session?.messageSubmissions.get(agentId)).length > 0,
|
|
);
|
|
}
|
|
|
|
function latestTasksFromStream(items: readonly StreamItem[]): TodoEntry[] {
|
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
const item = items[index];
|
|
if (item?.kind === "todo_list") return item.items;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function updateAgentTasks(
|
|
current: Map<string, TodoEntry[]>,
|
|
agentId: string,
|
|
taskSnapshot: TodoEntry[] | undefined,
|
|
): Map<string, TodoEntry[]> {
|
|
if (taskSnapshot === undefined || equal(current.get(agentId) ?? [], taskSnapshot)) return current;
|
|
const next = new Map(current);
|
|
if (taskSnapshot.length > 0) next.set(agentId, taskSnapshot);
|
|
else next.delete(agentId);
|
|
return next;
|
|
}
|
|
|
|
export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade";
|
|
|
|
// Per-session state
|
|
export interface SessionState {
|
|
serverId: string;
|
|
|
|
// Daemon client (immutable reference)
|
|
client: DaemonClient | null;
|
|
clientGeneration: number;
|
|
viewedTimelineSync: ViewedTimelineOwner | null;
|
|
|
|
// Server metadata (from server_info handshake)
|
|
serverInfo: DaemonServerInfo | null;
|
|
|
|
// Hydration status
|
|
hasHydratedAgents: boolean;
|
|
hasHydratedWorkspaces: boolean;
|
|
hasWorkspaceDirectorySnapshot: boolean;
|
|
|
|
// Audio state
|
|
isPlayingAudio: boolean;
|
|
|
|
// Focus
|
|
focusedAgentId: string | null;
|
|
focusedTerminalId: string | null;
|
|
|
|
// Stream state (head/tail model)
|
|
agentStreamTail: Map<string, StreamItem[]>;
|
|
agentStreamHead: Map<string, StreamItem[]>;
|
|
agentTasks: Map<string, TodoEntry[]>;
|
|
messageSubmissions: Map<string, MessageSubmissionRecord[]>;
|
|
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
|
|
agentTimelineHasOlder: Map<string, boolean>;
|
|
agentTimelineHasNewer: Map<string, boolean>;
|
|
agentTimelineOlderFetchInFlight: Map<string, boolean>;
|
|
historySyncGeneration: number;
|
|
agentHistorySyncGeneration: Map<string, number>;
|
|
agentAuthoritativeHistoryApplied: Map<string, boolean>;
|
|
|
|
// Initializing agents (used for UI loading state)
|
|
initializingAgents: Map<string, boolean>;
|
|
|
|
// Agents
|
|
agents: Map<string, Agent>;
|
|
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
|
agentDetails: Map<string, Agent>;
|
|
workspaces: Map<string, WorkspaceDescriptor>;
|
|
// All active project descriptors, keyed by host-local projectId.
|
|
projects: Map<string, ProjectDescriptor>;
|
|
// Transient restore state for archived workspaces, keyed by normalized
|
|
// workspaceId. Cleared in mergeWorkspaces when the descriptor lands.
|
|
restoringWorkspaces: Map<string, WorkspaceRestoreStatus>;
|
|
|
|
// Permissions
|
|
pendingPermissions: Map<string, PendingPermission>;
|
|
|
|
// File explorer
|
|
fileExplorer: Map<string, AgentFileExplorerState>;
|
|
|
|
// Queued messages
|
|
queuedMessages: Map<
|
|
string,
|
|
Array<{ id: string; text: string; attachments: ComposerAttachment[] }>
|
|
>;
|
|
}
|
|
|
|
// Global store state
|
|
interface SessionStoreState {
|
|
sessions: Record<string, SessionState>;
|
|
|
|
// Agent activity timestamps (top-level, keyed by agentId to prevent cascade rerenders)
|
|
agentLastActivity: Map<string, Date>;
|
|
}
|
|
|
|
// Action types
|
|
interface SessionStoreActions {
|
|
// Session management
|
|
initializeSession: (
|
|
serverId: string,
|
|
client: DaemonClient | null,
|
|
clientGeneration?: number,
|
|
) => void;
|
|
clearSession: (serverId: string) => void;
|
|
getSession: (serverId: string) => SessionState | undefined;
|
|
updateSessionClient: (serverId: string, client: DaemonClient, clientGeneration?: number) => void;
|
|
setViewedTimelineSync: (serverId: string, sync: ViewedTimelineOwner | null) => void;
|
|
updateSessionServerInfo: (serverId: string, info: DaemonServerInfo) => void;
|
|
|
|
// Audio state
|
|
setIsPlayingAudio: (serverId: string, playing: boolean) => void;
|
|
|
|
// Focus
|
|
setFocusedAgentId: (serverId: string, agentId: string | null) => void;
|
|
setFocusedTerminalId: (serverId: string, terminalId: string | null) => void;
|
|
|
|
// Stream state (head/tail model)
|
|
setAgentStreamTail: (
|
|
serverId: string,
|
|
state:
|
|
| Map<string, StreamItem[]>
|
|
| ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>),
|
|
) => void;
|
|
setAgentStreamHead: (
|
|
serverId: string,
|
|
state:
|
|
| Map<string, StreamItem[]>
|
|
| ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>),
|
|
) => void;
|
|
setAgentStreamState: (
|
|
serverId: string,
|
|
agentId: string,
|
|
state: {
|
|
tail?: StreamItem[];
|
|
head?: StreamItem[];
|
|
acknowledgedClientMessageIds?: readonly string[];
|
|
taskSnapshot?: TodoEntry[];
|
|
},
|
|
) => void;
|
|
beginAgentMessageSubmission: (
|
|
serverId: string,
|
|
agentId: string,
|
|
message: UserMessageItem,
|
|
) => void;
|
|
acceptAgentMessageSubmission: (
|
|
serverId: string,
|
|
agentId: string,
|
|
clientMessageId: string,
|
|
) => void;
|
|
rejectAgentMessageSubmission: (
|
|
serverId: string,
|
|
agentId: string,
|
|
clientMessageId: string,
|
|
) => MessageSubmissionRejectionOutcome;
|
|
handoffCreatedAgentUserMessage: (
|
|
serverId: string,
|
|
agentId: string,
|
|
message: UserMessageItem,
|
|
) => boolean;
|
|
clearAgentStreamHead: (serverId: string, agentId: string) => void;
|
|
setAgentTimelineCursor: (
|
|
serverId: string,
|
|
state:
|
|
| Map<string, AgentTimelineCursorState>
|
|
| ((prev: Map<string, AgentTimelineCursorState>) => Map<string, AgentTimelineCursorState>),
|
|
) => void;
|
|
setAgentTimelineHasOlder: (
|
|
serverId: string,
|
|
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
|
|
) => void;
|
|
setAgentTimelineHasNewer: (
|
|
serverId: string,
|
|
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
|
|
) => void;
|
|
setAgentTimelineOlderFetchInFlight: (
|
|
serverId: string,
|
|
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
|
|
) => void;
|
|
bumpHistorySyncGeneration: (serverId: string) => void;
|
|
markAgentHistorySynchronized: (serverId: string, agentId: string) => void;
|
|
setAgentAuthoritativeHistoryApplied: (
|
|
serverId: string,
|
|
agentId: string,
|
|
applied: boolean,
|
|
) => void;
|
|
applyAgentTimelineResponseState: (
|
|
serverId: string,
|
|
agentId: string,
|
|
state: {
|
|
items: StreamItem[];
|
|
head: StreamItem[];
|
|
range: AgentTimelineCursorState | null;
|
|
older: "available" | "none" | "unchanged";
|
|
newer: boolean;
|
|
synchronized: boolean;
|
|
acknowledgedClientMessageIds: string[];
|
|
},
|
|
) => void;
|
|
|
|
// Initializing agents
|
|
setInitializingAgents: (
|
|
serverId: string,
|
|
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
|
|
) => void;
|
|
|
|
// Agents
|
|
setAgents: (
|
|
serverId: string,
|
|
agents: Map<string, Agent> | ((prev: Map<string, Agent>) => Map<string, Agent>),
|
|
) => void;
|
|
setAgentDetails: (
|
|
serverId: string,
|
|
agents: Map<string, Agent> | ((prev: Map<string, Agent>) => Map<string, Agent>),
|
|
) => void;
|
|
setWorkspaces: (
|
|
serverId: string,
|
|
workspaces:
|
|
| Map<string, WorkspaceDescriptor>
|
|
| ((prev: Map<string, WorkspaceDescriptor>) => Map<string, WorkspaceDescriptor>),
|
|
) => void;
|
|
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void;
|
|
removeWorkspace: (serverId: string, workspaceId: string) => void;
|
|
setProjects: (serverId: string, projects: Iterable<ProjectDescriptor>) => void;
|
|
upsertProject: (serverId: string, project: ProjectDescriptor) => void;
|
|
removeProject: (serverId: string, projectId: string) => void;
|
|
setWorkspaceRestoreStatus: (
|
|
serverId: string,
|
|
workspaceId: string,
|
|
status: WorkspaceRestoreStatus,
|
|
) => void;
|
|
clearWorkspaceRestoreStatus: (serverId: string, workspaceId: string) => void;
|
|
|
|
// Agent activity timestamps
|
|
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
|
|
setAgentLastActivityBatch: (
|
|
updates: Map<string, Date> | ((prev: Map<string, Date>) => Map<string, Date>),
|
|
) => void;
|
|
flushAgentLastActivity: () => void;
|
|
|
|
// Permissions
|
|
setPendingPermissions: (
|
|
serverId: string,
|
|
perms:
|
|
| Map<string, PendingPermission>
|
|
| ((prev: Map<string, PendingPermission>) => Map<string, PendingPermission>),
|
|
) => void;
|
|
|
|
// File explorer
|
|
setFileExplorer: (
|
|
serverId: string,
|
|
state:
|
|
| Map<string, AgentFileExplorerState>
|
|
| ((prev: Map<string, AgentFileExplorerState>) => Map<string, AgentFileExplorerState>),
|
|
) => void;
|
|
|
|
// Queued messages
|
|
setQueuedMessages: (
|
|
serverId: string,
|
|
value:
|
|
| Map<string, Array<{ id: string; text: string; attachments: ComposerAttachment[] }>>
|
|
| ((
|
|
prev: Map<string, Array<{ id: string; text: string; attachments: ComposerAttachment[] }>>,
|
|
) => Map<string, Array<{ id: string; text: string; attachments: ComposerAttachment[] }>>),
|
|
) => void;
|
|
|
|
// Hydration
|
|
setHasHydratedAgents: (serverId: string, hydrated: boolean) => void;
|
|
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
|
|
setHasWorkspaceDirectorySnapshot: (serverId: string, available: boolean) => void;
|
|
|
|
// Agent directory (derived from agents)
|
|
getAgentDirectory: (serverId: string) => AgentDirectoryEntry[] | undefined;
|
|
}
|
|
|
|
type SessionStore = SessionStoreState & SessionStoreActions;
|
|
|
|
const agentLastActivityCoalescer = createAgentLastActivityCoalescer();
|
|
|
|
// Helper to create initial session state
|
|
function createInitialSessionState(
|
|
serverId: string,
|
|
client: DaemonClient | null,
|
|
clientGeneration = 0,
|
|
): SessionState {
|
|
return {
|
|
serverId,
|
|
client,
|
|
clientGeneration,
|
|
viewedTimelineSync: null,
|
|
serverInfo: null,
|
|
hasHydratedAgents: false,
|
|
hasHydratedWorkspaces: false,
|
|
hasWorkspaceDirectorySnapshot: false,
|
|
isPlayingAudio: false,
|
|
focusedAgentId: null,
|
|
focusedTerminalId: null,
|
|
agentStreamTail: new Map(),
|
|
agentStreamHead: new Map(),
|
|
agentTasks: new Map(),
|
|
messageSubmissions: new Map(),
|
|
agentTimelineCursor: new Map(),
|
|
agentTimelineHasOlder: new Map(),
|
|
agentTimelineHasNewer: new Map(),
|
|
agentTimelineOlderFetchInFlight: new Map(),
|
|
historySyncGeneration: 0,
|
|
agentHistorySyncGeneration: new Map(),
|
|
agentAuthoritativeHistoryApplied: new Map(),
|
|
initializingAgents: new Map(),
|
|
agents: new Map(),
|
|
workspaceAgentActivity: new Map(),
|
|
agentDetails: new Map(),
|
|
workspaces: new Map(),
|
|
projects: new Map(),
|
|
restoringWorkspaces: new Map(),
|
|
pendingPermissions: new Map(),
|
|
fileExplorer: new Map(),
|
|
queuedMessages: new Map(),
|
|
};
|
|
}
|
|
|
|
function areServerCapabilitiesEqual(
|
|
current: ServerCapabilities | undefined,
|
|
next: ServerCapabilities | undefined,
|
|
): boolean {
|
|
return JSON.stringify(current ?? null) === JSON.stringify(next ?? null);
|
|
}
|
|
|
|
function areServerInfoFeaturesEqual(
|
|
current: ServerInfoStatusPayload["features"] | undefined,
|
|
next: ServerInfoStatusPayload["features"] | undefined,
|
|
): boolean {
|
|
return JSON.stringify(current ?? null) === JSON.stringify(next ?? null);
|
|
}
|
|
|
|
function isSessionServerInfoUnchanged(input: {
|
|
currentServerInfo: SessionState["serverInfo"] | undefined;
|
|
nextHostname: string | null;
|
|
nextVersion: string | null;
|
|
nextDesktopManaged: boolean | undefined;
|
|
nextCapabilities: ServerCapabilities | undefined;
|
|
nextFeatures: ServerInfoStatusPayload["features"] | undefined;
|
|
nextServerId: string;
|
|
}): boolean {
|
|
const {
|
|
currentServerInfo,
|
|
nextHostname,
|
|
nextVersion,
|
|
nextDesktopManaged,
|
|
nextCapabilities,
|
|
nextFeatures,
|
|
} = input;
|
|
const prevHostname = currentServerInfo?.hostname?.trim() || null;
|
|
const prevVersion = currentServerInfo?.version?.trim() || null;
|
|
return (
|
|
currentServerInfo?.serverId === input.nextServerId &&
|
|
prevHostname === nextHostname &&
|
|
prevVersion === nextVersion &&
|
|
currentServerInfo?.desktopManaged === nextDesktopManaged &&
|
|
areServerCapabilitiesEqual(currentServerInfo?.capabilities, nextCapabilities) &&
|
|
areServerInfoFeaturesEqual(currentServerInfo?.features, nextFeatures)
|
|
);
|
|
}
|
|
|
|
export const useSessionStore = create<SessionStore>()(
|
|
subscribeWithSelector((set, get) => {
|
|
const commitActivityUpdates: AgentLastActivityCommitter = (updates) => {
|
|
set((prev) => {
|
|
let nextActivity: Map<string, Date> | null = null;
|
|
for (const [agentId, timestamp] of updates.entries()) {
|
|
const current = prev.agentLastActivity.get(agentId);
|
|
if (current && current.getTime() >= timestamp.getTime()) {
|
|
continue;
|
|
}
|
|
if (!nextActivity) {
|
|
nextActivity = new Map(prev.agentLastActivity);
|
|
}
|
|
nextActivity.set(agentId, timestamp);
|
|
}
|
|
if (!nextActivity) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
agentLastActivity: nextActivity,
|
|
};
|
|
});
|
|
};
|
|
agentLastActivityCoalescer.setCommitter(commitActivityUpdates);
|
|
|
|
return {
|
|
sessions: {},
|
|
agentLastActivity: new Map(),
|
|
|
|
// Session management
|
|
initializeSession: (serverId, client, clientGeneration) => {
|
|
set((prev) => {
|
|
if (prev.sessions[serverId]) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: createInitialSessionState(serverId, client, clientGeneration),
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
clearSession: (serverId) => {
|
|
agentLastActivityCoalescer.flushNow();
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
const nextSessions = { ...prev.sessions };
|
|
delete nextSessions[serverId];
|
|
let nextActivity = prev.agentLastActivity;
|
|
if (session.agents.size > 0 || session.agentDetails.size > 0) {
|
|
const candidate = new Map(prev.agentLastActivity);
|
|
let changed = false;
|
|
for (const agentId of new Set([
|
|
...session.agents.keys(),
|
|
...session.agentDetails.keys(),
|
|
])) {
|
|
if (candidate.delete(agentId)) {
|
|
changed = true;
|
|
}
|
|
agentLastActivityCoalescer.deletePending(agentId);
|
|
}
|
|
if (changed) {
|
|
nextActivity = candidate;
|
|
}
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: nextSessions,
|
|
agentLastActivity: nextActivity,
|
|
};
|
|
});
|
|
},
|
|
|
|
updateSessionClient: (serverId, client, clientGeneration = 0) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
|
|
if (session.client === client && session.clientGeneration === clientGeneration) {
|
|
return prev;
|
|
}
|
|
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: {
|
|
...session,
|
|
client,
|
|
clientGeneration,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
setViewedTimelineSync: (serverId, viewedTimelineSync) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session || session.viewedTimelineSync === viewedTimelineSync) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, viewedTimelineSync },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
updateSessionServerInfo: (serverId, info) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
|
|
const nextHostname = info.hostname?.trim() || null;
|
|
const nextVersion = info.version?.trim() || null;
|
|
const nextDesktopManaged = info.desktopManaged;
|
|
const nextCapabilities = info.capabilities;
|
|
const nextFeatures = info.features;
|
|
|
|
if (
|
|
isSessionServerInfoUnchanged({
|
|
currentServerInfo: session.serverInfo,
|
|
nextHostname,
|
|
nextVersion,
|
|
nextDesktopManaged,
|
|
nextCapabilities,
|
|
nextFeatures,
|
|
nextServerId: info.serverId,
|
|
})
|
|
) {
|
|
return prev;
|
|
}
|
|
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: {
|
|
...session,
|
|
serverInfo: {
|
|
serverId: info.serverId,
|
|
hostname: nextHostname,
|
|
version: nextVersion,
|
|
...(nextDesktopManaged !== undefined
|
|
? { desktopManaged: nextDesktopManaged }
|
|
: {}),
|
|
...(nextCapabilities ? { capabilities: nextCapabilities } : {}),
|
|
...(nextFeatures ? { features: nextFeatures } : {}),
|
|
},
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
getSession: (serverId) => {
|
|
return get().sessions[serverId];
|
|
},
|
|
|
|
// Audio state
|
|
setIsPlayingAudio: (serverId, playing) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session || session.isPlayingAudio === playing) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, isPlayingAudio: playing },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
// Focus
|
|
setFocusedAgentId: (serverId, agentId) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session || session.focusedAgentId === agentId) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: {
|
|
...session,
|
|
focusedAgentId: agentId,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
setFocusedTerminalId: (serverId, terminalId) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session || session.focusedTerminalId === terminalId) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: {
|
|
...session,
|
|
focusedTerminalId: terminalId,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
// Stream state (head/tail model)
|
|
setAgentStreamTail: (serverId, state) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
const nextState = typeof state === "function" ? state(session.agentStreamTail) : state;
|
|
if (session.agentStreamTail === nextState) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, agentStreamTail: nextState },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
setAgentStreamHead: (serverId, state) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
const nextState = typeof state === "function" ? state(session.agentStreamHead) : state;
|
|
if (session.agentStreamHead === nextState) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, agentStreamHead: nextState },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
setAgentStreamState: (serverId, agentId, state) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
|
|
let nextTail = session.agentStreamTail;
|
|
let nextHead = session.agentStreamHead;
|
|
let changedTail = false;
|
|
let changedHead = false;
|
|
|
|
if (state.tail !== undefined) {
|
|
const existingTail = session.agentStreamTail.get(agentId);
|
|
if (existingTail !== state.tail) {
|
|
nextTail = new Map(session.agentStreamTail);
|
|
nextTail.set(agentId, state.tail);
|
|
changedTail = true;
|
|
}
|
|
}
|
|
|
|
if (state.head !== undefined) {
|
|
const existingHead = session.agentStreamHead.get(agentId);
|
|
const shouldDeleteHead = state.head.length === 0;
|
|
if (shouldDeleteHead) {
|
|
if (session.agentStreamHead.has(agentId)) {
|
|
nextHead = new Map(session.agentStreamHead);
|
|
nextHead.delete(agentId);
|
|
changedHead = true;
|
|
}
|
|
} else if (existingHead !== state.head) {
|
|
nextHead = new Map(session.agentStreamHead);
|
|
nextHead.set(agentId, state.head);
|
|
changedHead = true;
|
|
}
|
|
}
|
|
|
|
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
|
|
const observedSubmissions = observeMessageSubmissionCanonical(
|
|
currentSubmissions,
|
|
state.acknowledgedClientMessageIds ?? [],
|
|
);
|
|
const changedSubmissions = observedSubmissions !== currentSubmissions;
|
|
const agentTasks = updateAgentTasks(session.agentTasks, agentId, state.taskSnapshot);
|
|
const changedTasks = agentTasks !== session.agentTasks;
|
|
|
|
if (!changedTail && !changedHead && !changedSubmissions && !changedTasks) {
|
|
return prev;
|
|
}
|
|
|
|
let messageSubmissions = session.messageSubmissions;
|
|
if (changedSubmissions) {
|
|
messageSubmissions = new Map(session.messageSubmissions);
|
|
if (observedSubmissions.length > 0) {
|
|
messageSubmissions.set(agentId, observedSubmissions);
|
|
} else {
|
|
messageSubmissions.delete(agentId);
|
|
}
|
|
}
|
|
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: {
|
|
...session,
|
|
agentStreamTail: nextTail,
|
|
agentStreamHead: nextHead,
|
|
agentTasks,
|
|
messageSubmissions,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
beginAgentMessageSubmission: (serverId, agentId, message) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) return prev;
|
|
if (!message.clientMessageId) {
|
|
throw new Error("Beginning a message submission requires client identity");
|
|
}
|
|
const currentTail = session.agentStreamTail.get(agentId) ?? [];
|
|
const currentHead = session.agentStreamHead.get(agentId) ?? [];
|
|
const stream = appendSubmittedUserMessage({
|
|
tail: currentTail,
|
|
head: currentHead,
|
|
message,
|
|
});
|
|
const submissions = beginMessageSubmission(
|
|
session.messageSubmissions.get(agentId) ?? [],
|
|
{ clientMessageId: message.clientMessageId },
|
|
);
|
|
const messageSubmissions = new Map(session.messageSubmissions);
|
|
messageSubmissions.set(agentId, submissions);
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: {
|
|
...session,
|
|
agentStreamTail:
|
|
stream.tail === currentTail
|
|
? session.agentStreamTail
|
|
: new Map(session.agentStreamTail).set(agentId, stream.tail),
|
|
agentStreamHead:
|
|
stream.head === currentHead
|
|
? session.agentStreamHead
|
|
: new Map(session.agentStreamHead).set(agentId, stream.head),
|
|
messageSubmissions,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
acceptAgentMessageSubmission: (serverId, agentId, clientMessageId) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) return prev;
|
|
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
|
|
const submissions = acceptMessageSubmission(currentSubmissions, clientMessageId);
|
|
if (submissions === currentSubmissions) return prev;
|
|
const messageSubmissions = new Map(session.messageSubmissions);
|
|
if (submissions.length > 0) {
|
|
messageSubmissions.set(agentId, submissions);
|
|
} else {
|
|
messageSubmissions.delete(agentId);
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, messageSubmissions },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
rejectAgentMessageSubmission: (serverId, agentId, clientMessageId) => {
|
|
let outcome: MessageSubmissionRejectionOutcome = "unknown";
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) return prev;
|
|
const currentTail = session.agentStreamTail.get(agentId) ?? [];
|
|
const currentHead = session.agentStreamHead.get(agentId) ?? [];
|
|
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
|
|
const result = rejectMessageSubmission(currentSubmissions, clientMessageId);
|
|
outcome = result.outcome;
|
|
if (outcome === "unknown") return prev;
|
|
const stream =
|
|
outcome === "rejected"
|
|
? removeSubmittedUserMessage({
|
|
tail: currentTail,
|
|
head: currentHead,
|
|
clientMessageId,
|
|
})
|
|
: { tail: currentTail, head: currentHead };
|
|
const messageSubmissions = new Map(session.messageSubmissions);
|
|
if (result.submissions.length > 0) {
|
|
messageSubmissions.set(agentId, result.submissions);
|
|
} else {
|
|
messageSubmissions.delete(agentId);
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: {
|
|
...session,
|
|
agentStreamTail:
|
|
stream.tail === currentTail
|
|
? session.agentStreamTail
|
|
: new Map(session.agentStreamTail).set(agentId, stream.tail),
|
|
agentStreamHead:
|
|
stream.head === currentHead
|
|
? session.agentStreamHead
|
|
: new Map(session.agentStreamHead).set(agentId, stream.head),
|
|
messageSubmissions,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
return outcome;
|
|
},
|
|
|
|
handoffCreatedAgentUserMessage: (serverId, agentId, message) => {
|
|
let didHandoff = false;
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
|
|
const currentTail = session.agentStreamTail.get(agentId) ?? [];
|
|
const currentHead = session.agentStreamHead.get(agentId) ?? [];
|
|
const result = handoffCreatedAgentUserMessageToStream({
|
|
tail: currentTail,
|
|
head: currentHead,
|
|
message,
|
|
});
|
|
if (!result.changedTail && !result.changedHead) {
|
|
return prev;
|
|
}
|
|
|
|
const nextTail = result.changedTail
|
|
? new Map(session.agentStreamTail).set(agentId, result.tail)
|
|
: session.agentStreamTail;
|
|
const nextHead = result.changedHead
|
|
? new Map(session.agentStreamHead).set(agentId, result.head)
|
|
: session.agentStreamHead;
|
|
didHandoff = true;
|
|
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: {
|
|
...session,
|
|
agentStreamTail: nextTail,
|
|
agentStreamHead: nextHead,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
return didHandoff;
|
|
},
|
|
|
|
clearAgentStreamHead: (serverId, agentId) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
if (!session.agentStreamHead.has(agentId)) {
|
|
return prev;
|
|
}
|
|
const nextHead = new Map(session.agentStreamHead);
|
|
nextHead.delete(agentId);
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, agentStreamHead: nextHead },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
setAgentTimelineCursor: (serverId, state) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
const nextState =
|
|
typeof state === "function" ? state(session.agentTimelineCursor) : state;
|
|
if (session.agentTimelineCursor === nextState) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, agentTimelineCursor: nextState },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
setAgentTimelineHasOlder: (serverId, state) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
const nextState =
|
|
typeof state === "function" ? state(session.agentTimelineHasOlder) : state;
|
|
if (session.agentTimelineHasOlder === nextState) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, agentTimelineHasOlder: nextState },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
setAgentTimelineHasNewer: (serverId, state) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) return prev;
|
|
const nextState =
|
|
typeof state === "function" ? state(session.agentTimelineHasNewer) : state;
|
|
if (session.agentTimelineHasNewer === nextState) return prev;
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, agentTimelineHasNewer: nextState },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
setAgentTimelineOlderFetchInFlight: (serverId, state) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
const nextState =
|
|
typeof state === "function" ? state(session.agentTimelineOlderFetchInFlight) : state;
|
|
if (session.agentTimelineOlderFetchInFlight === nextState) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, agentTimelineOlderFetchInFlight: nextState },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
bumpHistorySyncGeneration: (serverId) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
const nextGeneration = session.historySyncGeneration + 1;
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: {
|
|
...session,
|
|
historySyncGeneration: nextGeneration,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
markAgentHistorySynchronized: (serverId, agentId) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
const currentGeneration = session.historySyncGeneration;
|
|
const previousGeneration = session.agentHistorySyncGeneration.get(agentId);
|
|
if (previousGeneration === currentGeneration) {
|
|
return prev;
|
|
}
|
|
const nextMap = new Map(session.agentHistorySyncGeneration);
|
|
nextMap.set(agentId, currentGeneration);
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: {
|
|
...session,
|
|
agentHistorySyncGeneration: nextMap,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
setAgentAuthoritativeHistoryApplied: (serverId, agentId, applied) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
|
|
const previousApplied = session.agentAuthoritativeHistoryApplied.get(agentId) ?? false;
|
|
if (previousApplied === applied) {
|
|
return prev;
|
|
}
|
|
|
|
const nextApplied = new Map(session.agentAuthoritativeHistoryApplied);
|
|
if (applied) {
|
|
nextApplied.set(agentId, true);
|
|
} else {
|
|
nextApplied.delete(agentId);
|
|
}
|
|
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: {
|
|
...session,
|
|
agentAuthoritativeHistoryApplied: nextApplied,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
applyAgentTimelineResponseState: (serverId, agentId, state) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) return prev;
|
|
|
|
const nextTail = new Map(session.agentStreamTail);
|
|
nextTail.set(agentId, state.items);
|
|
const nextHead = new Map(session.agentStreamHead);
|
|
if (state.head.length > 0) nextHead.set(agentId, state.head);
|
|
else nextHead.delete(agentId);
|
|
const nextCursor = new Map(session.agentTimelineCursor);
|
|
if (state.range) nextCursor.set(agentId, state.range);
|
|
else nextCursor.delete(agentId);
|
|
const nextHasOlder = new Map(session.agentTimelineHasOlder);
|
|
if (state.older !== "unchanged") {
|
|
nextHasOlder.set(agentId, state.older === "available");
|
|
}
|
|
const nextHasNewer = new Map(session.agentTimelineHasNewer);
|
|
nextHasNewer.set(agentId, state.newer);
|
|
const nextAuthoritative = new Map(session.agentAuthoritativeHistoryApplied);
|
|
const nextSyncGeneration = new Map(session.agentHistorySyncGeneration);
|
|
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
|
|
const observedSubmissions = observeMessageSubmissionCanonical(
|
|
currentSubmissions,
|
|
state.acknowledgedClientMessageIds,
|
|
);
|
|
let messageSubmissions = session.messageSubmissions;
|
|
if (observedSubmissions !== currentSubmissions) {
|
|
messageSubmissions = new Map(session.messageSubmissions);
|
|
if (observedSubmissions.length > 0) {
|
|
messageSubmissions.set(agentId, observedSubmissions);
|
|
} else {
|
|
messageSubmissions.delete(agentId);
|
|
}
|
|
}
|
|
if (state.synchronized) {
|
|
nextAuthoritative.set(agentId, true);
|
|
nextSyncGeneration.set(agentId, session.historySyncGeneration);
|
|
}
|
|
const tasks = latestTasksFromStream([...state.items, ...state.head]);
|
|
const agentTasks = new Map(session.agentTasks);
|
|
if (tasks.length > 0) agentTasks.set(agentId, tasks);
|
|
else agentTasks.delete(agentId);
|
|
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: {
|
|
...session,
|
|
agentStreamTail: nextTail,
|
|
agentStreamHead: nextHead,
|
|
agentTasks,
|
|
agentTimelineCursor: nextCursor,
|
|
agentTimelineHasOlder: nextHasOlder,
|
|
agentTimelineHasNewer: nextHasNewer,
|
|
agentAuthoritativeHistoryApplied: nextAuthoritative,
|
|
agentHistorySyncGeneration: nextSyncGeneration,
|
|
messageSubmissions,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
// Initializing agents
|
|
setInitializingAgents: (serverId, state) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
const nextState = typeof state === "function" ? state(session.initializingAgents) : state;
|
|
if (session.initializingAgents === nextState) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, initializingAgents: nextState },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
// Agents
|
|
setAgents: (serverId, agents) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
const nextAgents = typeof agents === "function" ? agents(session.agents) : agents;
|
|
if (session.agents === nextAgents) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: {
|
|
...session,
|
|
agents: nextAgents,
|
|
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(
|
|
nextAgents,
|
|
session.workspaceAgentActivity,
|
|
),
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
setAgentDetails: (serverId, agents) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
const nextAgents = typeof agents === "function" ? agents(session.agentDetails) : agents;
|
|
if (session.agentDetails === nextAgents) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, agentDetails: nextAgents },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
setWorkspaces: (serverId, workspaces) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
const nextWorkspaces =
|
|
typeof workspaces === "function" ? workspaces(session.workspaces) : workspaces;
|
|
const preservedWorkspaces = preserveMapIdentity(session.workspaces, nextWorkspaces);
|
|
if (session.workspaces === preservedWorkspaces) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, workspaces: preservedWorkspaces },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
setProjects: (serverId, projects) => {
|
|
const next = new Map<string, ProjectDescriptor>();
|
|
for (const project of projects) next.set(project.projectId, project);
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) return prev;
|
|
const preservedProjects = preserveMapIdentity(session.projects, next);
|
|
if (session.projects === preservedProjects) return prev;
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, projects: preservedProjects },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
upsertProject: (serverId, project) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session || equal(session.projects.get(project.projectId), project)) return prev;
|
|
const projects = new Map(session.projects);
|
|
projects.set(project.projectId, project);
|
|
return {
|
|
...prev,
|
|
sessions: { ...prev.sessions, [serverId]: { ...session, projects } },
|
|
};
|
|
});
|
|
},
|
|
|
|
removeProject: (serverId, projectId) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session?.projects.has(projectId)) return prev;
|
|
const projects = new Map(session.projects);
|
|
projects.delete(projectId);
|
|
return {
|
|
...prev,
|
|
sessions: { ...prev.sessions, [serverId]: { ...session, projects } },
|
|
};
|
|
});
|
|
},
|
|
|
|
setWorkspaceRestoreStatus: (serverId, workspaceId, status) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
if (session.restoringWorkspaces.get(workspaceId) === status) {
|
|
return prev;
|
|
}
|
|
// A late dir-gone timeout must not override a successful restore:
|
|
// only mark failed while still restoring and the descriptor is absent.
|
|
if (
|
|
status === "failed" &&
|
|
(session.restoringWorkspaces.get(workspaceId) !== "restoring" ||
|
|
session.workspaces.has(workspaceId))
|
|
) {
|
|
return prev;
|
|
}
|
|
const next = new Map(session.restoringWorkspaces);
|
|
next.set(workspaceId, status);
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, restoringWorkspaces: next },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
clearWorkspaceRestoreStatus: (serverId, workspaceId) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session || !session.restoringWorkspaces.has(workspaceId)) {
|
|
return prev;
|
|
}
|
|
const next = new Map(session.restoringWorkspaces);
|
|
next.delete(workspaceId);
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, restoringWorkspaces: next },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
mergeWorkspaces: (serverId, workspaces) => {
|
|
const nextEntries = Array.from(workspaces);
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session || nextEntries.length === 0) {
|
|
return prev;
|
|
}
|
|
const next = new Map(session.workspaces);
|
|
let changed = false;
|
|
// A descriptor arriving is the success signal for a pending restore:
|
|
// clear it at the source so every entry point converges to "ready".
|
|
let nextRestoring: Map<string, WorkspaceRestoreStatus> | null = null;
|
|
for (const workspace of nextEntries) {
|
|
if (session.restoringWorkspaces.has(workspace.id)) {
|
|
nextRestoring ??= new Map(session.restoringWorkspaces);
|
|
nextRestoring.delete(workspace.id);
|
|
changed = true;
|
|
}
|
|
const existing = next.get(workspace.id);
|
|
const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existing);
|
|
if (existing === nextWorkspace) {
|
|
continue;
|
|
}
|
|
next.set(workspace.id, nextWorkspace);
|
|
changed = true;
|
|
}
|
|
if (!changed) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: {
|
|
...session,
|
|
workspaces: next,
|
|
restoringWorkspaces: nextRestoring ?? session.restoringWorkspaces,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
removeWorkspace: (serverId, workspaceId) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
|
workspaces: session?.workspaces,
|
|
workspaceId,
|
|
});
|
|
if (!session || !workspaceKey) {
|
|
return prev;
|
|
}
|
|
const next = new Map(session.workspaces);
|
|
next.delete(workspaceKey);
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, workspaces: next },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
// Agent activity timestamps (top-level, does NOT mutate session object)
|
|
setAgentLastActivity: (agentId, timestamp) => {
|
|
agentLastActivityCoalescer.enqueue(agentId, timestamp);
|
|
},
|
|
|
|
setAgentLastActivityBatch: (updates) => {
|
|
set((prev) => {
|
|
const nextActivity =
|
|
typeof updates === "function" ? updates(prev.agentLastActivity) : updates;
|
|
if (nextActivity === prev.agentLastActivity) {
|
|
return prev;
|
|
}
|
|
if (nextActivity.size === 0) {
|
|
if (prev.agentLastActivity.size === 0) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
agentLastActivity: new Map(),
|
|
};
|
|
}
|
|
let changed = false;
|
|
for (const [agentId, timestamp] of nextActivity.entries()) {
|
|
const currentTimestamp = prev.agentLastActivity.get(agentId);
|
|
if (!currentTimestamp || currentTimestamp.getTime() !== timestamp.getTime()) {
|
|
changed = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!changed && nextActivity.size === prev.agentLastActivity.size) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
agentLastActivity: new Map(nextActivity),
|
|
};
|
|
});
|
|
},
|
|
|
|
flushAgentLastActivity: () => {
|
|
agentLastActivityCoalescer.flushNow();
|
|
},
|
|
|
|
// Permissions
|
|
setPendingPermissions: (serverId, perms) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
const nextPerms = typeof perms === "function" ? perms(session.pendingPermissions) : perms;
|
|
if (session.pendingPermissions === nextPerms) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, pendingPermissions: nextPerms },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
// File explorer
|
|
setFileExplorer: (serverId, state) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
const nextState = typeof state === "function" ? state(session.fileExplorer) : state;
|
|
if (session.fileExplorer === nextState) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, fileExplorer: nextState },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
// Queued messages
|
|
setQueuedMessages: (serverId, value) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session) {
|
|
return prev;
|
|
}
|
|
const nextValue = typeof value === "function" ? value(session.queuedMessages) : value;
|
|
if (session.queuedMessages === nextValue) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, queuedMessages: nextValue },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
// Hydration
|
|
setHasHydratedAgents: (serverId, hydrated) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session || session.hasHydratedAgents === hydrated) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, hasHydratedAgents: hydrated },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
setHasHydratedWorkspaces: (serverId, hydrated) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session || session.hasHydratedWorkspaces === hydrated) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, hasHydratedWorkspaces: hydrated },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
setHasWorkspaceDirectorySnapshot: (serverId, available) => {
|
|
set((prev) => {
|
|
const session = prev.sessions[serverId];
|
|
if (!session || session.hasWorkspaceDirectorySnapshot === available) return prev;
|
|
return {
|
|
...prev,
|
|
sessions: {
|
|
...prev.sessions,
|
|
[serverId]: { ...session, hasWorkspaceDirectorySnapshot: available },
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
// Agent directory - derived from agents (computed on-demand)
|
|
getAgentDirectory: (serverId) => {
|
|
const state = get();
|
|
const session = state.sessions[serverId];
|
|
if (!session) {
|
|
return undefined;
|
|
}
|
|
|
|
const entries: AgentDirectoryEntry[] = [];
|
|
for (const agent of session.agents.values()) {
|
|
// Get lastActivityAt from top-level slice, fallback to agent.lastActivityAt
|
|
const lastActivityAt = state.agentLastActivity.get(agent.id) ?? agent.lastActivityAt;
|
|
entries.push({
|
|
id: agent.id,
|
|
serverId,
|
|
title: agent.title ?? null,
|
|
status: agent.status,
|
|
turn: agent.turn,
|
|
lastActivityAt,
|
|
cwd: agent.cwd,
|
|
provider: agent.provider,
|
|
pendingPermissionCount: agent.pendingPermissions.length,
|
|
requiresAttention: agent.requiresAttention ?? false,
|
|
attentionReason: agent.attentionReason ?? null,
|
|
attentionTimestamp: agent.attentionTimestamp ?? null,
|
|
createdAt: agent.createdAt,
|
|
labels: agent.labels,
|
|
});
|
|
}
|
|
return entries;
|
|
},
|
|
};
|
|
}),
|
|
);
|
|
|
|
export function useWorkspaceRestoreStatus(
|
|
serverId: string | null,
|
|
workspaceId: string | null,
|
|
): WorkspaceRestoreStatus | null {
|
|
return useSessionStore((state) =>
|
|
serverId && workspaceId
|
|
? (state.sessions[serverId]?.restoringWorkspaces.get(workspaceId) ?? null)
|
|
: null,
|
|
);
|
|
}
|