- plan.md/.zh.md (+ plan-mode module JSDoc and README pair): the sole flush point is the prepended agent/step listener; prompt admission is pre-turn and never flushes — the pages had it backwards. - workspace.md/.zh.md: pending-mutation recovery deletes the marked table row — completing an interrupted delete but rolling back an interrupted create; 'completes exactly the marked mutation' oversold. - telemetry.md/.zh.md: delivery is best-effort with possible loss AND duplication, not at-most-once; TelemetryRecord.attributes JSDoc (source + both fences) now lists session.seed_length, which the coordinator emits for forked sessions. - Agent Note consequence bullet: nine pages cover ten services (storage owns two keys); client-modules.zh.md wire-single-source phrasing. Pairs re-recorded; plan-mode and telemetry suites green.
8.7 KiB
Workspaces
English | 中文
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, 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 and validates session membership against SessionHeader.cwd, 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; bootstrap and GUI ordering: Workspace UI product-flow Agent Note.
Source: packages/workspace/workspace/src/types.ts
Identity
/**
* 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. 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.
/**
* 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<void>
/**
* 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<void>
/**
* 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<void>
/**
* 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<void>
/**
* 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) 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); unknown ids return false. Create and delete persist a pending-mutation marker before their two writes (record + order) can diverge; startup resolves exactly the marked mutation — by deleting the marked table row, which completes an interrupted delete and rolls back an interrupted create (the registration is re-creatable, so rollback is the safe direction) — 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, 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 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 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.