# Workspaces English | [中文](workspace.zh.md) A workspace is the persistent record of a directory the user works in: a stable id over a canonical path, a display title, and the ordered account of sessions that belong to it. The subsystem is one package ([dsh-workspace](../../packages/workspace/workspace), `ctx.workspace`) — an optional host-side capability, not part of the agent-loop spine, and invisible to models (no tools, no prompt text, no session events). It stores its records through the [storage domain form](storage.md) and validates session membership against [`SessionHeader.cwd`](persistence.md#sessionheader--metadata-beside-the-log), so `storageDomain` and `sessionPersistence` are mandatory startup dependencies: an unavailable persistence peer leaves the plugin pending rather than being mistaken for an empty history. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md); bootstrap and GUI ordering: [Workspace UI product-flow Agent Note](../../.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md). Source: [`packages/workspace/workspace/src/types.ts`](../../packages/workspace/workspace/src/types.ts) ## Identity ```ts type-equiv /** * Identifies one workspace record. A generated uuid, never the path: path * normalization rewrites paths, and a reference anchor must stay stable. */ type WorkspaceId = Branded<'WorkspaceId'> ``` `WorkspaceId` is a [branded id](core.md#branded-ids). Path identity is separate: `realpathNormalize` (`fs.realpath`; trailing slashes, `..`, and symlinks resolved) is the one uniqueness canon — workspace paths are stored canonicalized, uniqueness is string equality of canonical paths (a symlink to an owned directory collides), and attach-time session cwd checks go through the same canon. ## The workspace entity Consumers see only the `Workspace` interface; the implementation stays package-private. ```ts type-equiv /** * One workspace: a stable id over an existing directory, a display title, and * an ordered candidate account of sessions. Membership requires both an id in * that account and a session header whose canonical cwd equals the workspace * path. Consumers only see this interface; the implementation stays private. */ interface Workspace { /** Stable record id (generated uuid). */ readonly id: WorkspaceId /** * Canonical directory path: the `fs.realpath` of the path given at create * time (trailing slashes, `..`, and symlinks all resolved). Never rewritten * afterwards, even when the directory disappears (see {@link status}). */ readonly path: string /** Display title. Defaults to `basename(path)` at create; duplicates are allowed. */ readonly title: string /** ISO-8601 creation instant, stamped at create and never rewritten. */ readonly createdAt: string /** ISO-8601 instant of the last durable mutation (create counts as one). */ readonly updatedAt: string /** * Header-validated sessions in manually owned order: a new session is * prepended at attach, explicit reordering goes through * `insertSessionBefore`, and activity never reorders. The durable candidate * account is filtered synchronously: missing headers, invalid cwd values, * and canonical cwd mismatches are never returned. A subsequent workspace * mutation prunes those filtered candidates durably. */ readonly sessionIds: readonly SessionId[] /** * Replace the display title durably. * @param title - New title; any string, duplicates across workspaces allowed. * @returns resolution after durability. */ setTitle(title: string): Promise /** * Prepend a session to this workspace's candidate account. An already * accounted id resolves without writing. A new id's live or persisted * header cwd must resolve to an existing directory equal to {@link path}; * unknown ids, missing or invalid cwd values, and mismatches reject without * writing. * @param sessionId - The session to record. * @returns resolution after durability. */ attachSession(sessionId: SessionId): Promise /** * Move an accounted session within the manual order, DOM-insertBefore-like: * with an anchor the session lands before it, without one it appends to the * end. Only the moved id changes position. A session or anchor absent from * the account rejects without writing; a move to the current position * resolves without writing (decided on the domain write chain). * @param sessionId - The accounted session to move. * @param beforeSessionId - Accounted anchor to insert before; omitted appends. * @returns resolution after durability. */ insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise /** * Remove a session from this workspace's account. Idempotent: an id not on * the account resolves without writing (decided on the domain write chain, * like attach). Never touches the session's own stored log. * @param sessionId - The session to remove. * @returns resolution after durability. */ detachSession(sessionId: SessionId): Promise /** * Live directory check, uncached: whether {@link path} currently exists and * is a directory. A missing directory never mutates the record — the * directory may only be temporarily moved. * @returns `'ok'` when the directory exists, `'missing-dir'` otherwise. */ status(): Promise<'ok' | 'missing-dir'> } ``` Ownership truth is the record's ordered `sessionIds`, never derived from session cwd — but membership requires both: an id on the account and a header whose canonical cwd equals the workspace path, so one session structurally belongs to at most one workspace. Failed writes reject (`insertSessionBefore` account errors as `WorkspaceMoveInvalidError`, storage failures as plain errors); every accepted mutation stamps `updatedAt` and durably prunes candidates that no longer pass the membership check. ## The registry: `ctx.workspace` `WorkspaceRegistry` ([signatures](../cordis-catalog/services.md#ctxworkspace--workspaceregistry)) owns registration and resolution. `create(path, title?)` canonicalizes the path, rejects a nonexistent path (the original `ENOENT`) or a non-directory, returns the existing entity unchanged when the canonical path is already owned, and otherwise creates a record with `title ?? basename(path)` prepended to the durable registry order — a new record cannot duplicate an existing display title (`WorkspaceNameConflictError`). `get(id)` and the ordered `list()` are synchronous cache reads; `resolveByPath(path)` applies the same realpath canon without creating. `delete(id)` removes only the registration, order entry, and session account — the directory, user files, live sessions, and persisted logs are never touched, so those sessions become Ungrouped ([decision](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)); unknown ids return `false`. Create and delete persist a pending-mutation marker before their two writes (record + order) can diverge; startup completes exactly the marked mutation, and an unmarked order/table mismatch fails loud as corruption. Sessions get their cwd at create time from whoever creates them, not from this registry — the API gateway resolves a new session's cwd from the chosen workspace's `path` (falling back to an explicit or default cwd), creates the session so the cwd lands in its immutable [`SessionHeader`](persistence.md#sessionheader--metadata-beside-the-log), then calls `attachSession`, which re-validates that stored header cwd against the workspace path. On the first successful start, the registry bootstraps history from persisted headers alone (`id`, `cwd`, `createdAt` — never event bodies), grouping sessions with a valid canonical cwd into per-directory workspaces, newest first; the initialized marker is written last so an interrupted bootstrap resumes safely. The bootstrap is one-time: cwd-less legacy sessions stay Ungrouped, and sessions created afterwards join a workspace only through `attachSession`. ## Consumers [dsh-host-apiproxy](../../packages/host/apiproxy) is the product consumer: it serves workspace CRUD to GUI clients over `ctx.workspace` and performs the create-session-then-attach flow above. [dsh-workspace-context](../../packages/context/workspace-context) is **not** a consumer despite the name: it discovers AGENTS.md-style instruction files under an agent's own cwd and never touches `ctx.workspace` — the shared word refers to the user's working directory, not to this registry's entities.