Skip to main content

ws-ckpt and Session Management IPC Protocols

中文版

Overview

cosh-ng communicates with the ws-ckpt daemon via Unix Domain Socket to manage workspace snapshots. Communication uses a frame format of bincode serialization + 4-byte little-endian length prefix.

Architecture

cosh-cli / cosh-core ws-ckpt daemon
│ │
│ Unix socket │
│ /run/ws-ckpt/ws-ckpt.sock │
│─────────────────────────────→│
│ [4B LE len][bincode req] │
│ │
│←─────────────────────────────│
│ [4B LE len][bincode resp] │

The client implementation is in crates/cosh-platform/src/checkpoint.rs (CkptClient), and type definitions are in crates/cosh-types/src/checkpoint.rs.

Frame Format

Each message consists of two parts:

┌──────────────────┬───────────────────────────────┐
│ 4-byte LE u32 │ bincode-encoded enum payload │
│ (payload length) │ (WsCkptRequest / Response) │
└──────────────────┴───────────────────────────────┘
  • Length prefix: little-endian unsigned 32-bit integer representing the byte count of the subsequent bincode payload
  • Maximum response limit: 64 MiB (prevents OOM)
  • Default timeout: 5000ms (configurable via CkptClient::with_timeout())

Request Types (WsCkptRequest)

bincode serializes enums by variant index (first variant = index 0). Variant order is the binary contract and must not be reordered.

IndexVariantDescription
0Init { workspace }Initialize a workspace
1Checkpoint { workspace, id, message, metadata, pin }Create a snapshot
2Rollback { workspace, to }Rollback to a specified snapshot
3Delete { workspace, snapshot, force }Delete a snapshot
4List { workspace, format }List snapshots
5Diff { workspace, from, to }Diff between two snapshots
6Status { workspace }Query status
7Cleanup { workspace, keep }Clean up old snapshots
8ConfigGet daemon configuration
9ReloadConfigReload configuration
10Recover { workspace }Recover a workspace
11HealthAdvisoryHealth check

Response Types (WsCkptResponse)

VariantCorresponding RequestKey Fields
InitOk { ws_id }InitWorkspace ID
CheckpointOk { snapshot_id }CheckpointSnapshot ID
RollbackOk { from, to }RollbackRollback source and target
DeleteOk { target }DeleteDeleted snapshot identifier
Error { code, message }AnyError code + human-readable description
ListOk { snapshots }ListVec<SnapshotEntry>
DiffOk { changes }DiffVec<DiffEntry>
StatusOk { report }StatusStatusReport
CleanupOk { removed }CleanupList of removed snapshot IDs
ConfigOk { config }ConfigConfigReport
ReloadConfigOkReloadConfigNo payload
CheckpointSkipped { reason }CheckpointSkip reason (e.g., no changes)
RecoverOk { workspace }RecoverRecovered workspace path
HealthAdvisoryOk { ... }HealthAdvisoryOver-limit workspace count, disk usage

Error Codes (WsCkptErrorCode)

IndexVariantDescription
0WorkspaceNotFoundWorkspace does not exist
1SnapshotNotFoundSnapshot does not exist
2AlreadyInitializedWorkspace already initialized
3BtrfsErrorBtrfs operation error
4IoErrorI/O error
5InvalidPathInvalid path
6ConfirmationRequiredConfirmation needed (e.g., deleting a pinned snapshot)
7InternalErrorInternal error
8SnapshotAlreadyExistsSnapshot ID conflict
9WriteLockConflictWrite lock conflict
10DiskSpaceInsufficientInsufficient disk space

Client Usage

use cosh_platform::checkpoint::CkptClient;

// Default path /run/ws-ckpt/ws-ckpt.sock
let client = CkptClient::default_path();

// Or specify path and timeout
let client = CkptClient::with_timeout("/custom/path.sock", 10000);

// Health check
if !client.is_available() {
eprintln!("ws-ckpt daemon not running");
}

// Operation examples
let result = client.create("/home/user/project", "snap-001", Some("initial"), None, false)?;
let list = client.list(Some("/home/user/project"))?;
let restored = client.restore("/home/user/project", "snap-001")?;

Key Constraints

ConstraintDescription
Variant order is immutablebincode serializes enums by index; reordering breaks the wire format
New additions append onlyNew Request/Response variants can only be added at the end
Types must stay in syncDefinitions in cosh-types must exactly match ws-ckpt-common
Timeout handlingClient sets read/write timeouts to avoid blocking when daemon is unresponsive
Length limitResponses exceeding 64 MiB are treated as anomalous; connection is dropped immediately
Socket pathDefault /run/ws-ckpt/ws-ckpt.sock; overridable via environment variable or CLI argument

Test Verification

cd src/cosh-ng

# bincode round-trip serialization tests
cargo test --locked -p cosh-types -- checkpoint

# Variant index contract tests
cargo test --locked -p cosh-types test_request_bincode_variant_index

# CkptClient unit tests (no running daemon required)
cargo test --locked -p cosh-platform -- checkpoint

cosh-core Session Management JSON Protocol

cosh-core --session-control is the stable internal boundary used by cosh-shell to discover, validate, and clear provider conversations. It handles one JSON request from standard input, writes one JSON response to standard output, and exits. This mode loads configuration and the scoped session store but does not initialize a provider, extensions, skills, hooks, or authentication.

The caller must send the canonical workspace path it intends to manage. Core canonicalizes the path again, derives its workspace-scoped store, and validates every lowercase canonical UUID before constructing a session filename. Core also loads the project configuration from <workspace>/.copilot-shell/config.toml; it never uses the management process's unrelated current directory for session.auto_persist or session.persist_dir. Standard input is capped at 1 MiB. Oversized input, invalid UTF-8, malformed JSON, and requests missing required fields return invalid_request without initializing storage.

Request Actions

Requests use an action discriminator:

{"action":"list","workspace_scope":"/work/project","limit":20,"cursor":null,"all_workspaces":false}
{"action":"inspect","workspace_scope":"/work/project","session_id":"2d711642-b726-4b04-8d2a-8a0470f4ed24"}
{"action":"validate","workspace_scope":"/work/project","session_id":"2d711642-b726-4b04-8d2a-8a0470f4ed24"}
{"action":"prepare_clear_all","workspace_scope":"/work/project","protected_session_ids":[],"limit":4096,"cursor":null}
{"action":"clear","workspace_scope":"/work/project","session_ids":["2d711642-b726-4b04-8d2a-8a0470f4ed24"],"protected_session_ids":[]}
ActionContract
listReturns newest-first summaries. limit defaults to 20 and is clamped to 1–100; pass the opaque next_cursor to read the next page. When all_workspaces is true, the shell requests a larger initial page size of 100 to reduce incomplete output, but it is still clamped to the same 1–100 core limit. Core enumerates every workspace-hash directory under the storage root, returns sessions from all workspaces, and marks foreign sessions with scope_mismatch. all_workspaces defaults to false for backward compatibility.
inspectReturns a summary even when health prevents recovery.
validateFully loads the envelope and succeeds only for a resumable session.
prepare_clear_allReturns a lexicographically paged clearable/protected ID plan without loading or transferring summaries. limit is clamped to 1–4096; continue with next_cursor. Omitting limit is accepted only when the complete plan fits one 4096-ID page, preventing older clients from silently accepting a partial plan.
clearDeletes each requested ID independently and returns per-item skipped errors. Each request accepts at most 128 IDs.

Summaries expose session_id, workspace_scope, creation and update times, model, message count, first prompt, schema version, and one of these health values: ready, corrupt, incompatible, or scope_mismatch. The first-prompt preview is normalized to one line and capped at 160 Unicode characters before serialization. Listing first orders UUID files by bounded filesystem metadata, then reads only the requested page instead of deserializing every history on every request. A session file is capped at 32 MiB for persistence, listing, validation, and resume; an oversized stored entry is reported as corrupt without allocating its contents. Listing skips an entry that disappears or cannot be read and continues scanning until the requested page is full or no candidates remain, so a filtered first candidate cannot hide healthy entries on the same page. Cursors encode the last newest-first filesystem sort key, so deleting that entry between page requests does not restart pagination.

Response Envelope

Successful data is tagged with the matching action:

{
"ok": true,
"data": {
"action": "list",
"sessions": [],
"next_cursor": null
}
}

A request-level failure exits with status 1:

{
"ok": false,
"error": {
"code": "not_found",
"message": "session not found: 2d711642-b726-4b04-8d2a-8a0470f4ed24",
"recoverable": true,
"hint": "Refresh the session list and choose an existing entry."
}
}

Stable error codes are invalid_id, invalid_cursor, invalid_request, not_found, io, corrupt, incompatible_version, scope_mismatch, conflict, and active_session. They are recoverable for interactive callers. A clear request with per-item failures still returns ok: true; failed entries appear in data.skipped with their own typed errors.

protected_session_ids is mandatory defense-in-depth for interactive deletion. cosh-shell sends both its selected and active provider IDs, and core refuses to delete either one even if it is also present in session_ids. Both prepare_clear_all and clear must include this field; an explicit empty array confirms that the caller has no protected identities, while omission rejects the complete request before any deletion. The shell drains bounded prepare_clear_all ID pages to show the exact plan before confirmation, then submits those IDs through 128-item clear batches; it does not drain every summary page. Core rejects oversized direct batches, and bounds per-item identifiers and error text by UTF-8 bytes. Core also checks the complete serialized envelope against a 1 MiB hard budget, so multi-byte input cannot bypass the client's response cap. Each summary independently bounds untrusted model metadata to 256 UTF-8 bytes and workspace metadata to 4096 UTF-8 bytes before page accumulation. A large but otherwise valid session file therefore cannot make list, inspect, or validate allocate an unbounded response or fall back to invalid_request.

If a later clear batch fails, the shell preserves confirmed deleted and skipped results. The failed batch is reported as unknown_session_ids, and IDs not yet sent are reported as unattempted_session_ids; the UI must not collapse this state into a request-level error that hides earlier deletion.

The shell gives each one-shot management operation a single ten-second deadline covering spawn, request-pipe writes, and response collection. Request writes use a nonblocking pipe and are retried by the deadline-aware lifecycle loop, so neither the leader nor a detached descendant can retain stdin and block a bulk clear writer indefinitely. A timeout or transport failure closes the request pipe, terminates the process group, escalates to forced termination, waits for the leader, and joins all output workers. Output workers use cancellable polling: after leader and process-group cleanup, they drain bytes that are already readable and stop even if a descendant that escaped the original process group still holds an inherited output descriptor. An ordinary poll timeout repeats the poll and never enters a blocking read, so a quiet leader cannot strand a reader before the lifecycle sets its stop flag. The client also caps the JSON response at 1 MiB and stderr diagnostics at 256 KiB. Crossing either limit closes the pipe and terminates and reaps the session-control process group.

Persistence Compatibility

Schema-v1 envelopes contain the immutable provider session UUID, canonical workspace, timestamps, model, optimistic generation, and model-visible messages. Writes use a same-directory temporary file, file and directory syncs, atomic rename, and a short-lived advisory lock. The kernel releases the lock when its process exits, so an unlocked lock file is reusable rather than treated as a conflict. A canonical workspace path must be valid UTF-8; Core returns invalid_request before deriving a scope or storage hash when it is not. Optimistic generations must advance monotonically, and a stored u64::MAX generation is rejected without replacing history. On Unix, scoped directories use mode 0700; session, temporary, and lock files use 0600. Legacy raw message arrays load as generation zero in memory. Core resolves symlinks in the storage root once, when the store is constructed, then securely opens or creates every scoped path component below that canonical root without following symlinks and pins the workspace-hash directory. Symlinked home or dotfile layouts therefore keep working while later symlink swaps below the root are refused. Scoped enumeration, session and lock opens, temporary-file creation, atomic rename, and removal are all relative to that descriptor; session and lock opens use NOFOLLOW. Replacing a workspace hash directory with another workspace's symlink therefore cannot redirect load, persist, list, or clear. Clearing a session also removes its paired lock file, and stale temporary files from crashed writers are swept when the directory is next opened for writing.

An explicit legacy lookup by canonical UUID checks only a former flat directory whose ownership by the requested workspace can be established. With the new default root, that means the requested workspace's former relative sessions/ directory. A custom root is eligible for legacy lookup only when its configured value is workspace-relative, contains no .. component, already exists as a directory, and resolves through symlinks inside the canonical workspace. Core opens the canonical workspace one path component at a time without following symlinks, then pins each eligible legacy directory with an open descriptor. Legacy enumeration, session opens, and removal remain relative to that descriptor; session opens use NOFOLLOW. A concurrent rename-and-symlink replacement therefore cannot redirect load or clear outside the pinned workspace-owned directory. Absolute, ~/, and parent-escaping roots do not participate in legacy lookup; they may name scoped storage, but scoped access rejects symlinks in every path component below the canonical storage root. Core never infers legacy ownership from directory-prefix containment and does not inspect the process cwd or an ambiguous shared flat root. Workspace-owned legacy sessions appear in list summaries beside scoped envelopes, so the picker, prepare_clear_all, and explicit clear observe the same population; ambiguous files outside an established workspace-owned directory are never claimed, listed, or rewritten by inspect, validate, or load. Explicit clear can remove even a corrupt legacy file while still honoring protected IDs. Migration locks the legacy source, atomically writes a schema-v1 envelope into the requested workspace scope, and then removes the old file. A legacy cleanup failure is reported as a typed persistence error and retried by later persists. When both copies exist, clear removes the legacy copy first, so a legacy permission failure can never delete the newer scoped history or resurrect stale content.

The JSONL headless protocol and this management protocol share SessionStore::load; interactive selection cannot bypass the validation used by direct cosh-core --resume.

A session-load failure is explicit on the JSONL result:

{"type":"result","is_error":true,"errors":["session recovery failed [not_found]: session not found"],"session_error_code":"not_found","session_error_phase":"load","session_id":"..."}

cosh-shell distinguishes selected recovery from automatic continuation of an active provider session. A Core session failure carries separate session_error_code and session_error_phase fields. A load failure with not_found, corrupt, incompatible_version, or scope_mismatch, or any typed persist failure, releases only the matching attempted identity. An owned selected attempt becomes failed while retaining the structured code, provider message, and phase-specific recovery hint; its previous active UUID remains available. Provider error text containing bracketed words cannot impersonate a session failure. The provider-reported ID must also match both selected and active resume attempts before Core identity is committed. Unrelated selected IDs remain selected. A one-turn disable provider resume hint omits --resume without consuming a pending user selection.

The JSONL system/init message includes session_resumable. A value of false means session.auto_persist is disabled: consumers must not capture the reported UUID and must invalidate an identity only when that identity was actually carried by this invocation's --resume. A fresh one-turn fallback therefore cannot consume an unrelated selected ID or an older active ID. The rule also applies when the turn later fails, is cancelled, or exits abnormally. The field is optional for compatibility with providers that do not implement this extension; absence retains the existing session-ID capture behavior.

The active ID, workspace, and invocation generation share one state lock. Every started turn receives a generation token, and success, failure, cancellation, non-resumable cleanup, and identity mismatch transitions commit only while that token still owns the latest attempt. A cancelled worker that finishes late therefore cannot clear or overwrite a newer turn, including a retry of the same selected ID. Structured session results are finalized before any subsequent transport failure is delivered. Replacing or rejecting a selection also advances the generation atomically. A fresh turn that does not carry --resume releases a superseded restoring owner back to selected, and cancellation applies any already parsed structured session failure before delivering AgentCancelled. Destructive session management holds the same state lease for the complete clear operation, while selection holds it across validation and commit. Clear and activation are therefore linearized rather than relying on a stale snapshot of protected IDs.

Test Verification

cd src/cosh-ng
cargo test --package cosh-core
cargo test --package cosh-shell --test protocol