# Changelog

## 3.22.1

### Patch Changes

- [#705](https://github.com/skaile-ai/workspaces/pull/705) [`d69c3ff`](https://github.com/skaile-ai/workspaces/commit/d69c3ff31fe4631936263d3dd48969972a68cbe4) Thanks [@Frozen666](https://github.com/Frozen666)! - fix(runner): answer a paused question before awaiting the driver swap

  A wake that delivered a skill while the first turn was paused on
  `AskUserQuestion` wedged the session permanently. `recreateAgentSession` arms
  `beginSwap` synchronously and then waits for the active turn; that turn only
  settles once `answerQuestion` resolves it; and `handleReply` awaited the swap
  before it looked for a pending question — swap waits on turn, turn waits on
  answer, answer waits on swap. Only a cancel escaped it.

  The reply router now answers a paused question on the current driver (the one
  the swap is waiting on, so it has not been disposed) and gates only the
  fall-through, which genuinely starts a new turn, on the swap.

  `attach_instance`'s skill branch also stops awaiting the driver rebuild, matching
  its MCP sibling: the platform caps a runner capability call at 30s and a rebuild
  routinely exceeds that, so each wake-delivered skill burned a 30s timeout even
  though staging succeeded in ~9ms.

## 3.22.0

### Minor Changes

- [#699](https://github.com/skaile-ai/workspaces/pull/699) [`22d8b8c`](https://github.com/skaile-ai/workspaces/commit/22d8b8c8a3f4d025bd258ec1c13d80a07d20584a) Thanks [@peteralbert](https://github.com/peteralbert)! - runner: replay the last `session_init_ack` on every gateway (re)connect — `replayed: true`, the capability signature recomputed at replay time, no `resumeProbe` — ahead of the turn-status replay, so a host that re-adopts a running container after a backend restart can keep it instead of re-waking the session into a new one

## 3.21.0

### Minor Changes

- [#696](https://github.com/skaile-ai/workspaces/pull/696) [`1277cc8`](https://github.com/skaile-ai/workspaces/commit/1277cc8f14f671df1817e2109dc99419dba06d2d) Thanks [@peteralbert](https://github.com/peteralbert)! - runner: replay the current turn status as the first event on every gateway (re)connect — `status` with the open phase while a turn is in flight, `status idle` otherwise — so a re-adopted platform session entry never reads an open turn as idle, and a `finished` lost during a disconnect self-heals

## 3.20.1

### Patch Changes

- [#693](https://github.com/skaile-ai/workspaces/pull/693) [`93c5e8c`](https://github.com/skaile-ai/workspaces/commit/93c5e8c4ae94200089e2968251345e0efddb1ec0) Thanks [@peteralbert](https://github.com/peteralbert)! - runner: clamp the AI-credential refresh timer to 24 days so a far-future expiry re-arms instead of hot-looping on `setTimeout`'s silent 1 ms overflow

## 3.20.0

### Minor Changes

- [#688](https://github.com/skaile-ai/workspaces/pull/688) [`bfb4426`](https://github.com/skaile-ai/workspaces/commit/bfb4426ca3bb72e70e9648fd25915b438c95a6cc) Thanks [@mortegro](https://github.com/mortegro)! - Refresh managed source clones on every install instead of serving a stale cache.

  `AssetManager.loadConfig()` turns each `sources:` entry into a path-backed
  declaration under the global clone cache, and `ensureRepo` short-circuited on a
  cache hit — so `install` / `add` / `lock` never pulled and a clone kept serving
  the commit it was first cloned at, silently. `ensureRepo` now routes every
  managed cache clone (url-backed, under `getGlobalCacheDir()`) through one
  refresh path: pull when unpinned, check out the pin when one is given, never
  touch a clone skaile does not own (bare `path:` sources, dev links, the bundled
  factory tree).

  A failed refresh is no longer swallowed: it warns with the clone path and git's
  own error and keeps the cached commit, so an offline install still works. The
  new `pullRepoResult(dest, branch)` export is the detailed form of `pullRepo`
  that carries that error.

  `install --locked` also now actually moves a cache clone to the lock's commit —
  the old cache-hit short-circuit ignored the pin it was already given, so a
  locked restore verified against whatever the clone happened to be on. `add()`
  and `lock()` now pass the declared `sources[].pin` as well, so a pinned source
  is never pulled to the branch tip (and `lock()` cannot launder that SHA into
  `skaile.lock.yaml`).

  Cost to note: an unpinned source now hits the network on `add` / `lock` /
  `install`, where a cache hit used to be offline and instant. Offline runs pay
  one connect timeout per source, then warn and continue. A source pinned to a
  SHA (including every lockfile `commit`) stays offline once cloned — the pin
  checkout is skipped when the clone is already on it.

  `sourceCacheKey` now also strips userinfo, so a source declared with an
  in-url credential (`https://x-access-token:<token>@host/o/r`) keys the same
  clone as its plain form — the token is no longer a directory name, and no
  longer reaches a log line that names the key. `ssh://git@host/o/r` re-keys too
  (`git@host/o/r` → `host/o/r`), unifying it with the https and scp forms of the
  same repo. An affected clone re-clones once under the clean path; see
  MIGRATION.md. Because the key no longer distinguishes credentials, a managed
  clone's `origin` is re-pointed at the declared url on every run, so a rotated
  token takes effect instead of the clone fetching with the revoked one.

### Patch Changes

- [#683](https://github.com/skaile-ai/workspaces/pull/683) [`5a7548b`](https://github.com/skaile-ai/workspaces/commit/5a7548b29756bc247d4086e619d22fae47291d8c) Thanks [@Frozen666](https://github.com/Frozen666)! - git connector: recover a checkout wedged by an abandoned `.git/index.lock`

  The driver SIGKILLs every git child on timeout, which gives git no chance to release its index lock, and the checkout outlives the container that made it — so a single killed child wedged every later `connect()` on that repo, permanently. Connect now drops a lock that is provably abandoned (older than the longest deadline any git child of this driver runs under); a younger lock is left alone, because yanking one a live git still holds would corrupt its index write.

## 3.19.0

### Minor Changes

- [#674](https://github.com/skaile-ai/workspaces/pull/674) [`95ff13e`](https://github.com/skaile-ai/workspaces/commit/95ff13ef72e86c721b9107744ed4cc5614794e81) Thanks [@peteralbert](https://github.com/peteralbert)! - Add explicit approve-once/deny managed MCP consent using the original native callback and configured transport binding. Preserve the native elicitation envelope in the generated protocol; reject unsupported approval forms, stale replies and changed bindings through the final RPC write. Consent cards add the `mcp` kind with bounded durable presentation, without replacing capability or Platform authorization.

  Update strict consent consumers as described in MIGRATION.md. Synthetic driver/runner regressions and a separately constrained allowed-only fixture harness do not establish native/provider readiness or production activation.

  Sample final passive MCP counters after owned transport closure, so rejected post-terminal SDK entries cannot disappear from the final audit. Uncertain closure or a failed counter read does not claim a completed final sample.

## 3.18.0

### Minor Changes

- [#656](https://github.com/skaile-ai/workspaces/pull/656) [`b82c03e`](https://github.com/skaile-ai/workspaces/commit/b82c03eaa659e01bd6d0c0cac52fe1c82cbcc90f) Thanks [@peteralbert](https://github.com/peteralbert)! - Add an explicit managed App Server implementation behind the existing Codex driver, with dedicated authentication, private instance state, account-bound resume, correlated queued turns and bounded cancellation/disposal. Preserve no-option standalone SDK behavior. Reuse shared credential validation with bounded mint-time clock skew and strict expiry. This supplies the driver lifecycle seam for tool integration; it does not advertise Codex support or establish native/provider/Linux readiness.

- [#663](https://github.com/skaile-ai/workspaces/pull/663) [`08b3b0e`](https://github.com/skaile-ai/workspaces/commit/08b3b0e20102a42260cbb62458eb53058a7b68ea) Thanks [@peteralbert](https://github.com/peteralbert)! - Wire managed Codex into the production serve CLI through an explicit private, nonsecret host configuration. Validate launcher paths and model policy before feature advertisement; bind existing init credentials and profiles without ambient auth or project overrides, then initialize the driver before acknowledging the session.

  Use the existing account-bound subscription refresh RPC with cancellation. Keep changed bindings fail-closed, preserve ordinary Claude startup, and decline unsupported CLI native approval grants. Publication, native qualification and Platform private-state compatibility remain separate adoption requirements.

  Cancel and join an in-progress native initialize handshake before managed disposal releases its private instance lease, including shutdown before serve initialization is acknowledged.

- [#659](https://github.com/skaile-ai/workspaces/pull/659) [`101ffda`](https://github.com/skaile-ai/workspaces/commit/101ffda6e84f2189f4cfe0d051c5e5dcdc1164ab) Thanks [@peteralbert](https://github.com/peteralbert)! - Run managed Codex flow subprompts in disposable isolated drivers with one admitted native turn and the existing five-minute deadline. A native turn may make multiple internal provider requests; report and account for every response through cumulative native usage, including cache reads, context, and the actual model on successful and failed turns.

  Preserve the first response's usage when resuming a thread with no prior usage, and join child disposal after node timeouts before draining or disconnect completes.

- [#658](https://github.com/skaile-ai/workspaces/pull/658) [`6886372`](https://github.com/skaile-ai/workspaces/commit/6886372dac1675c658e0d451b40e93777bc0455a) Thanks [@peteralbert](https://github.com/peteralbert)! - Add the explicit managed Codex runner MCP adapter, correlated native questions and approval hooks, and scoped image/file/skill inputs. Reuse existing registry and connector/flow handlers with cancellation and live inventory checks. This source does not advertise the gated runtime feature or authorize complete runtime publication.

- [#664](https://github.com/skaile-ai/workspaces/pull/664) [`87d0818`](https://github.com/skaile-ai/workspaces/commit/87d08189831140f9691585868571de38ec73cdee) Thanks [@peteralbert](https://github.com/peteralbert)! - Version managed Codex state by session/account binding under the existing private lease. Positively classified unavailable native state can use one same-binding fresh initialization and the existing DB restore event; other failures remain fatal. Clarify that native permission consent lasts for the current turn.

- [#663](https://github.com/skaile-ai/workspaces/pull/663) [`08b3b0e`](https://github.com/skaile-ai/workspaces/commit/08b3b0e20102a42260cbb62458eb53058a7b68ea) Thanks [@peteralbert](https://github.com/peteralbert)! - Carry exact question request identities through reply actions and add managed
  native approval consent over the existing question transport. Native grants
  require the active managed private policy and explicit approval for one request.

  Require the explicit host-owned privateRoot for managed native policy composition;
  deny the full private mount and retain that policy across thread, turn and native
  approval handling. Published runtime adoption and live isolation evidence remain required.

### Patch Changes

- [#667](https://github.com/skaile-ai/workspaces/pull/667) [`50c0c86`](https://github.com/skaile-ai/workspaces/commit/50c0c8677387975b53fd615533ff03ff890bf3c8) Thanks [@peteralbert](https://github.com/peteralbert)! - Add opt-in strict external MCP disposal that reports failed client closes after attempting all owned cleanup. The native tool harness reuses the production runtime manager for an explicitly approved public external-tool observation.

- [#672](https://github.com/skaile-ai/workspaces/pull/672) [`8c5e3c2`](https://github.com/skaile-ai/workspaces/commit/8c5e3c2da0df5b692ab70c83068ac10d539ad613) Thanks [@peteralbert](https://github.com/peteralbert)! - Add passive, closed managed MCP startup and request-count diagnostics without changing native admission or turn behavior.

- [#669](https://github.com/skaile-ai/workspaces/pull/669) [`e9ce1b5`](https://github.com/skaile-ai/workspaces/commit/e9ce1b5d703ee4e7665ca352ca8984bb9ad06b40) Thanks [@mortegro](https://github.com/mortegro)! - Stop zod's recursive `JSONType` from reaching `FlowDefinition` (TS2589).

  `input.example` and `defaults.run_input` were typed with `z.json()`, whose
  inferred type is zod's self-recursive `util.JSONType`. Consumers that wrap a
  `FlowDefinition` in a Vue `ref()` hit "Type instantiation is excessively deep
  and possibly infinite": Vue's `UnwrapRefSimple` maps arrays through a
  homomorphic mapped type, minting a fresh anonymous type at every level and
  defeating TypeScript's recursion-identity check. Only the anonymous _array_
  member of the union triggers it.

  Replaced with an explicit `FlowJsonValue` whose array member is a named
  `FlowJsonArray` interface, so the recursion resolves to a stable identity.
  The accepted value space is unchanged. Covered by a type test in
  `@skaile/workspace-bridge-vue`.

- [#668](https://github.com/skaile-ai/workspaces/pull/668) [`71e3067`](https://github.com/skaile-ai/workspaces/commit/71e306791288de979a6908ca12e1c951583c74fe) Thanks [@mortegro](https://github.com/mortegro)! - Point the legacy-manifest errors at a migration path instead of a dead end.

  `assertNoLegacyTopLevelKeys` rejected `repositories:`, `ai_resources:`,
  `publisher:`, `version:` and `assets:` with a bare "unknown top-level key",
  leaving no indication of what replaced them. The keys were removed in 0.22.0,
  so every consumer still carrying one is already broken at `resolveSkWorkspaceConfig`
  — the error is the only place they find out. Each message now names the
  replacement and links the migration guide.

  Note for anyone hitting this: the check is `!== undefined`, so an empty
  `repositories: {}` throws identically. `decodeSkaileYaml` stays total and merely
  records `legacy_key_rejected`; only the runtime boundary re-throws, which is why
  a preview pane can look healthy while `serve` cannot start.

## 3.17.0

### Minor Changes

- [#653](https://github.com/skaile-ai/workspaces/pull/653) [`f648965`](https://github.com/skaile-ai/workspaces/commit/f64896576ecea955c540494033745e6a301252ba) Thanks [@peteralbert](https://github.com/peteralbert)! - Add a narrow production Codex App Server stdio client derived from the retained native protocol, with bounded requests, native callbacks and safe terminal disposal. Extend protocol 3.11 with optional runtime feature requirements, explicit OpenAI auth mode, dedicated AI credential delivery and subscription refresh contracts. Unsupported managed Codex initialization fails before credentials are consumed; existing Claude and standalone Codex behavior remains unchanged. This does not advertise or enable a Codex runtime.

## 3.16.2

### Patch Changes

- [#650](https://github.com/skaile-ai/workspaces/pull/650) [`49a4659`](https://github.com/skaile-ai/workspaces/commit/49a4659d64eadb40f56e443815f9036326369a5f) Thanks [@Frozen666](https://github.com/Frozen666)! - Line-anchor the echo driver's `[test-cap:<name>]` sentinel so it survives host
  prompt decoration (skaile-ai/platform#1053).

  Hosts decorate outbound user messages, so the keyword never sat at string
  start and the old message-anchored `/^\s*\[test-cap:…\]\s*(.*)$/s` never fired
  — leaving the capability-dispatch seam unreachable from an e2e. This is the
  same bug `[test-flow]` had, and it gets the same fix that shipped in [#401](https://github.com/skaile-ai/workspaces/issues/401):
  `/^[ \t]*\[test-cap:([\w.\-_]+)\][ \t]*(.*)$/m`, matching a line that _begins_
  with the exact token.

  Dropping the `s` flag is load-bearing, not cosmetic: under dotAll the greedy
  `(.*)$` swallows the trailing `\n</user_message>` into the JSON body, so
  `JSON.parse` throws and the capability input silently degrades to `{}`
  (`invalid_input` for `platform.open_file`). `[ \t]` rather than `\s` keeps the
  token anchored to its own line.

  Dropping `s` also narrows the body to a SINGLE line: a pretty-printed,
  multi-line JSON body used to parse under dotAll and now degrades to `{}`. No
  in-repo caller does that, but callers must keep the JSON on the sentinel's own
  line.

  Callers arm the sentinel on its own line — send `"\n[test-cap:…] {json}\n"` —
  exactly as the platform's `flow-persistence.spec.ts` already does for
  `[test-flow]`; a bare sentinel is inline with the `<user_message>` wrapper and
  does not arm.

- [#644](https://github.com/skaile-ai/workspaces/pull/644) [`9eb4884`](https://github.com/skaile-ai/workspaces/commit/9eb4884b548f9b6556d874f04d0551c9be570c76) Thanks [@Frozen666](https://github.com/Frozen666)! - Report per-phase elapsed ms across the session wake path, so the wall-clock of a
  tier-1 resume can be attributed for the first time (skaile-ai/workspaces#421).

  A resume of a large conversation can hold a queued prompt for minutes, and none
  of that time was accounted for anywhere: `serve.ts` carried no elapsed-ms
  instrumentation at all, so the second session build and the SDK replaying the
  transcript were indistinguishable from a log. The runner now reports
  `session_build`, `session_recreate`, `session_rebuild` (split into dispose and
  create), `session_ready`, `prompt_gate_wait` and `driver_start` under a uniform
  `phase` field; the claude-sdk driver reports `transcript_scrub` and
  `query_first_message`, the two costs that sit past the readiness gate.

  Observability only — no behaviour change. The double build this measures is
  still there: the resume hint reaches the session only through the recreate,
  because the honor/reject capability signature needs the first build's wired
  registry.

## 3.16.1

### Patch Changes

- [#640](https://github.com/skaile-ai/workspaces/pull/640) [`50ea98f`](https://github.com/skaile-ai/workspaces/commit/50ea98f22854f897d810831a15c6a451c77052a6) Thanks [@Frozen666](https://github.com/Frozen666)! - Stop the chat reporting work while a turn is parked on an `AskUserQuestion` (skaile-ai/platform#1399). The driver emits one `tool_call` per entry in the assistant message's `toolCalls`, and the question block is in that list — so the `status: working` it normalized to landed one event after the `question` reset the phase to idle, and nothing followed to correct it while the user read the prompt. The normalizer now stays silent about the question tool, and the store refuses a busy phase while a question is pending.

  Not covered: a multi-sub-question `AskUserQuestion` still reports work early, because the driver resumes only once every sub-question has an answer while the store clears its pending question on the first (pre-existing in `reply()`).

## 3.16.0

### Minor Changes

- [#628](https://github.com/skaile-ai/workspaces/pull/628) [`f2069be`](https://github.com/skaile-ai/workspaces/commit/f2069be99b7e1d42020a92510a8aaa0d369a4fe2) Thanks [@mortegro](https://github.com/mortegro)! - fix(serve): stop the unbounded EPIPE log loop on a broken stdout pipe ([#627](https://github.com/skaile-ai/workspaces/issues/627))

  `serve`'s `uncaughtException` handler logged through the same stdout sink whose
  failure raised it. When a parent closed the child's stdout pipe, each log line
  raised `EPIPE`, the handler logged that, and the log line raised `EPIPE` again.
  Marking the exception "(non-fatal)" also removed the exit an unhandled `EPIPE`
  would normally have caused, so nothing terminated the cycle: 237,752 copies of
  one stack trace and 1.0 GB of `logs.db` in under four hours.

  The existing `try/catch` in `LogStore.flushSync` looked like it covered this but
  could not: writes to a pipe are asynchronous, so `EPIPE` surfaces on the
  stream's `error` event after `writeBatch()` has already returned.

  Guards at each place the cycle passes through:

  - `StdoutSink` goes quiet once its stream is destroyed, ended, or marked broken.
    It deliberately does _not_ claim the stream's `error` event: doing so
    suppresses Node's fatal-EPIPE exit, which is right for a long-lived server and
    wrong for a CLI piped into `head`. Ownership of that event sits with
    `watchOutputStream`, which only `serve` calls.
  - A broken output pipe silences the sinks in every mode. The exit is separate:
    new `ServeOptions.exitOnBrokenPipe`, off by default so a library caller's
    process is never killed, opted into by `skaile serve` and opted back out by
    `SKAILE_SERVE_EXIT_ON_BROKEN_STDOUT=0` (set by the `local` / `nix` deploy
    targets, whose children outlive the CLI by design).
  - `watchOutputStream` is the sole owner of stdout's `error` event, and is
    installed as soon as the LogStore exists so the guarantee covers boot too.
    It is deliberately separate from `installProcessSafetyNet`, which downgrades
    uncaught errors to logged and must wait until startup has succeeded — during
    boot a failure before the transport listens still has to be fatal.
    Claiming that event is what keeps the async failure out of `uncaughtException`
    and so out of the sink that raised it.
  - `installProcessSafetyNet` still logs the errors it downgrades, through the
    sinks that remain writable (SQLite, WebSocket), instead of dropping them when
    stdout is gone. It refuses to re-enter itself, so a failure raised while
    logging cannot be logged in turn.
  - `LogStore`'s last-resort `process.stderr` fallback no longer throws when
    stderr is dead too, which is how the periodic flush timer joined the loop.

## 3.15.0

### Minor Changes

- [#635](https://github.com/skaile-ai/workspaces/pull/635) [`22ffa9c`](https://github.com/skaile-ai/workspaces/commit/22ffa9cd74ad9f9e9d233e0f3c7b2243f819ba15) Thanks [@mortegro](https://github.com/mortegro)! - Honour the configured Anthropic base URL in model discovery

  Anthropic model discovery hardcoded `api.anthropic.com`. Behind a gateway the
  credential in play is the gateway's, so that request 401s, discovery returns an
  empty catalog, and any caller that reads "empty" as "unknown" degrades into
  "accept any model" — which is how a bad model ID reached a live session with no
  validation error. Consumers were working around it with their own gateway-aware
  fetch.

  `fetchProviderModels`, `getModels`, and `validateApiKey` take an optional
  `{ baseUrl, env }` argument and resolve the endpoint through the new
  `resolveProviderModelsUrl`, whose precedence is explicit `baseUrl` → the
  provider's own base-URL env var (`ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL`) →
  the provider's public API. Joining is tolerant of a trailing slash and of a
  base that already ends in `/v1`.

  `ClaudeSdkDriver.listModels()` now reports a catalog under `cloud: gateway`,
  resolving the base URL from `cloudConfig.baseUrl` → config env → ambient env and
  the credential from `cloudSecrets` (auth token first, mirroring
  `buildClaudeCloudEnv`). Bedrock, Vertex, and Azure still report nothing — those
  address models through their own control planes, where an Anthropic
  `/v1/models` call is the wrong endpoint and a key leak.

  Behaviour with no base URL configured is unchanged. New exports:
  `resolveProviderModelsUrl`, `FetchModelsOptions`, and the `provider-endpoints`
  module from the bridge index.

- [#635](https://github.com/skaile-ai/workspaces/pull/635) [`22ffa9c`](https://github.com/skaile-ai/workspaces/commit/22ffa9cd74ad9f9e9d233e0f3c7b2243f819ba15) Thanks [@mortegro](https://github.com/mortegro)! - Accept the current Anthropic model lineup in agent manifests

  `ModelIdSchema` demanded a version with at least two numeric components
  (`claude-sonnet-4-5`), so every current single-digit-major ID — `claude-opus-5`,
  `claude-sonnet-5`, `claude-fable-5` — failed validation outright and a manifest
  could not name a model that demonstrably exists. `fable` was also missing from
  the family vocabulary entirely. Authors were pushed onto family aliases or
  stale IDs, and `normalizeAnthropicModel` silently downgraded a perfectly good
  current ID to `sonnet`.

  The version segment now accepts one or more numeric components, and the
  family/version core is shared by the full, Bedrock, and Vertex patterns instead
  of being spelled out three times. New exports: `ANTHROPIC_MODEL_FAMILIES` and
  `AnthropicModelFamily`.

  Consequence worth knowing: `claude-opus-4` is structurally indistinguishable
  from `claude-opus-5`, so it now validates too and
  `normalizeAnthropicModel("claude-opus-4")` returns it unchanged rather than
  coercing to `"opus"`. This validator is structural, not a catalog check — an ID
  no provider serves is caught at dispatch.

  `fable` is deliberately _not_ added to `ANTHROPIC_MODEL_ALIASES`: it is a real
  ID family but has no Claude Code rolling alias, so a malformed `claude-fable-*`
  falls through to the caller's fallback rather than to an alias the runtime
  would reject.

- [#635](https://github.com/skaile-ai/workspaces/pull/635) [`22ffa9c`](https://github.com/skaile-ai/workspaces/commit/22ffa9cd74ad9f9e9d233e0f3c7b2243f819ba15) Thanks [@mortegro](https://github.com/mortegro)! - Widen the omp child-env allowlist and surface failed omp requests

  The omp child process was started with an env allowlist carrying exactly one
  provider credential — `ANTHROPIC_API_KEY`. omp is model-agnostic and selects a
  provider from whichever credential it finds, so a workspace configured for any
  other provider had nothing in the child to authenticate with and silently fell
  back. `ANTHROPIC_BASE_URL` was dropped too, so an Anthropic-compatible gateway
  was bypassed and traffic went to Anthropic's own API instead. Downstream this
  was carried as a local `patch-package` patch, i.e. lost on any reinstall or
  version bump.

  The allowlist now mirrors the "Environment Variables" section of `omp --help` —
  the authoritative statement of what the child actually reads — across Anthropic
  (direct, gateway, Foundry/mTLS), the other first-party providers, the
  aggregators, the cloud model planes, and the search backends. It remains an
  allowlist: names outside it, including anything a platform provisions under its
  own naming, still do not reach the child. Exported as
  `OMP_PROVIDER_ENV_KEYS`, alongside `buildOmpChildEnv`.

  Separately, omp reports a failed request as a normal `message_end` carrying
  `stopReason: "error"`, not as an `error` event. The driver ignored that, so a
  failed turn completed silently with no assistant text and no failure — it read
  as a hung or empty agent. It now emits a classified `error` agent-event on the
  same channel as every other driver error. `message_start` also seeds the
  delta tracker from its own content instead of clearing it, so a start frame
  that already carries text no longer causes the first chunk to be emitted twice.

- [#635](https://github.com/skaile-ai/workspaces/pull/635) [`22ffa9c`](https://github.com/skaile-ai/workspaces/commit/22ffa9cd74ad9f9e9d233e0f3c7b2243f819ba15) Thanks [@mortegro](https://github.com/mortegro)! - Declare `tools` on the agent manifest and forward it to the omp driver

  An agent definition's `tools:` block was never declared on
  `AgentManifestSchema`; it survived only because that schema is a loose object.
  So it was untyped at every read site, unvalidated, and — because the omp driver
  never passed it on — enforced on the Claude SDK path while doing nothing at all
  on omp. A consuming app could not trim omp's toolset, which matters because
  omp's default toolset dominates the fixed token prefix of every request.

  `tools` is now a declared field backed by `AgentToolsSchema` (exported,
  alongside the `AgentTools` type), with `allowed` and `denied` typed as string
  arrays.

  The omp driver translates the manifest's canonical Claude Code tool names into
  omp's built-in names and passes `--tools a,b,c`. omp has no deny flag, so a
  deny list is applied by subtracting from omp's default toolset; a restriction
  that leaves nothing enabled becomes `--no-tools`. Names omp has no built-in for
  (`WebFetch`, `mcp__*`) are dropped, because `--tools` governs built-ins only. A
  manifest with no `tools` block passes no flag and keeps omp's full default
  toolset exactly as before. Exported as `buildOmpToolArgs`, `OMP_BUILTIN_TOOLS`,
  and `OMP_DEFAULT_TOOLS`.

  Note that a malformed `tools:` block now fails manifest validation where it
  previously slipped through untyped.

- [#635](https://github.com/skaile-ai/workspaces/pull/635) [`22ffa9c`](https://github.com/skaile-ai/workspaces/commit/22ffa9cd74ad9f9e9d233e0f3c7b2243f819ba15) Thanks [@mortegro](https://github.com/mortegro)! - Stop pinning a default model in the built-in settings and in `skaile init`

  `SETTINGS_DEFAULTS.model` was `"claude-sonnet-4-6"`, and both `loadSettings` and
  `resolveSettings` spread the built-in defaults _under_ every other layer. That made
  the pin a floor rather than a default: deleting `model` from `.skaile/settings.json`
  did not unset it, the built-in came back. A consumer could therefore never express
  "no model configured", and could not route an unconfigured session to its own
  per-backend default — it silently inherited whatever id was compiled in here.

  The failure mode is not hypothetical. A host app removed the model from every
  settings source it owned, created a workspace, and got `claude-sonnet-4-6` back in
  its project metadata; its own per-backend default resolution was unreachable because
  the field was never falsy by the time it ran. When the pinned id is one the account
  cannot serve — retired, or rate-limited below the request's fixed token prefix —
  every session starts on it and every retry fails the same way.

  `skaile init --backend claude-code` carried the same id in `BACKEND_DEFAULTS` and
  wrote it into the `agent-config` block of each generated skaile.yaml, pinning every
  new workspace at creation. `omp` and `codex` already omitted a model there;
  `claude-code` is now consistent with them.

  Both defaults are now unset, so the driver applies its own current default: omp
  omits `--model` and lets omp choose, `claude-sdk` falls back to the `sonnet` rolling
  alias. Nothing else in the resolution chain changes — an explicit `model` in
  settings, skaile.yaml, `PREFERRED_MODEL`, or a CLI override behaves exactly as
  before.

  `PROVIDER_MODEL_TIERS.anthropic.default` is deliberately untouched. It is a
  tier-selection table with a test-enforced correspondence to `STATIC_MODELS`, not a
  settings floor, and choosing the Anthropic default tier is a separate decision.

## 3.14.1

### Patch Changes

- [#630](https://github.com/skaile-ai/workspaces/pull/630) [`cbdb41c`](https://github.com/skaile-ai/workspaces/commit/cbdb41cb1e6c8e92df956ab0cd8951d5b4e0a00e) Thanks [@Frozen666](https://github.com/Frozen666)! - Stop a compaction from silently eating a user turn.

  Compaction prompts the same driver a user turn does, and the runner suppresses
  forwarded events while it runs — so a message that arrived mid-compaction was
  accepted, executed against a busy driver, and had its reply dropped on the way
  out. No text, no `finished`, no error: the chat sat on "AI is thinking…" over
  work the agent had actually done.

  Prompts and replies now wait for the driver through a `CompactionGate`, mirroring
  the existing `DriverSwapGate`. The gate stays shut until the whole compaction
  call has settled — wider than the event suppression, which the orchestrator lifts
  early so the snapshot-injection turn stays visible — and a compaction that holds
  the driver past `SKAILE_COMPACTION_GATE_TIMEOUT_MS` (default 60 minutes) now
  reports a visible error instead of swallowing the turn.

  `onCompactionEnd` is also now guaranteed exactly once per compaction. Every
  in-band call sat on a return path, so a throw between the start callback and one
  of them left the host suppressing every agent event for the rest of the session.

  Driving issue: skaile-ai/platform#3882.

## 3.14.0

### Minor Changes

- [#622](https://github.com/skaile-ai/workspaces/pull/622) [`d2e1975`](https://github.com/skaile-ai/workspaces/commit/d2e1975aa88f00d275547b47b771424925dec9c8) Thanks [@peteralbert](https://github.com/peteralbert)! - Fix two residual compaction failure modes. A compaction `prompt()` that never settles is now bounded at 10 minutes (`SKAILE_COMPACTION_TIMEOUT_MS`, resolved per call) and aborted, recording a classified `timeout` instead of waiting out the driver's 30-minute turn-stall watchdog and reporting an opaque `unknown` with zero output tokens; a threshold compaction that fails that way retries once with a narrowed focus (which asks for tighter prose, never less coverage, so it cannot override the merge instruction below), waiting for the aborted turn to unwind first. Every failed attempt now starts the cooldown, not just a retried one, so a wedged session stops re-attempting a full-context compaction on every turn. A re-compaction now carries the previous summary into the prompt verbatim with an explicit merge instruction, so an already-compacted history no longer draws a short "the previous summary still holds" reply that validation rejects as `too_short`. A model published under a 1M-context alias is floored at a 1M window, since the SDK reports the base 200k window for those aliases and made them compact far too early. Both failure paths now persist truncated diagnostics: `CompactionAttemptEvent` gains an optional `errorMessage`, mirrored into `summary` (failed attempts keep `status: 'failed'` and `validatedAt: null`, so they can never be restored into a session).

## 3.13.0

### Minor Changes

- [#612](https://github.com/skaile-ai/workspaces/pull/612) [`5fa7243`](https://github.com/skaile-ai/workspaces/commit/5fa724344584de6798fd80d6d460c4d1933e1ef4) Thanks [@Frozen666](https://github.com/Frozen666)! - Mount `read` strips a trailing run of NUL bytes from a text file before decoding it. A cloud mount whose remote over-reports a file's length (SharePoint metadata sizes drift from the bytes served) hands back a zero-padded tail from the rclone cache; served as characters, an editor saved the pad back into the file as real bytes.

## 3.12.0

### Minor Changes

- [#610](https://github.com/skaile-ai/workspaces/pull/610) [`f304ccf`](https://github.com/skaile-ai/workspaces/commit/f304ccfb3df48fa9a477cbd9e3fe72682a832db2) Thanks [@Frozen666](https://github.com/Frozen666)! - `ErrorEvent` gains the optional usage fields `FinishedEvent` already carries (`costUsd`, `tokens`, `modelUsage`, `contextTokens`, `contextWindow`). The normalizer forwards them from a `result` whose subtype is not `success` (`error_max_turns`, `error_during_execution`), so a consumer that ledgers usage on `finished` can ledger the failed turn too — the provider bills it either way. The claude-sdk driver now puts `tokens` and, when the SDK reported a `total_cost_usd`, the turn's `costUsd` delta on its non-success `result` too (it already carried the per-model fields). Errors that are not a turn result do not carry the fields, and unlike the `finished` branch there is no `costUsd: 0` fallback: an absent figure stays absent. Closes the gap the 3.11.0 changeset noted.

- [#609](https://github.com/skaile-ai/workspaces/pull/609) [`12894c9`](https://github.com/skaile-ai/workspaces/commit/12894c98de96b8243e0c7eb3a2ae48db843b2b5c) Thanks [@Frozen666](https://github.com/Frozen666)! - `ListOptions` (the `options` of a `resource_request` `list`) gains an optional `includeHidden` flag. The runner's filesystem-mount listing skipped every entry whose name starts with `.` unconditionally and silently, which is the right default for a browsing listing but drops `.env.example`, `.npmrc`, `.nvmrc` and every other conventionally-committed dotfile when a caller uses the listing to materialise a build context from a connector mount. `includeHidden: true` lifts the skip; without it the listing is byte-for-byte what it was.

  The flag is honored only by the filesystem-mount `list` (connector drivers ignore it) and it carves nothing out: `.git`, `.cache` and every other dot-directory are listed too, and hidden entries count against `MOUNT_LIST_MAX_ENTRIES` like any other, so an over-cap listing still throws instead of returning a short list. A caller staging a tree should therefore list one directory at a time and prune `.git`, `.cache`, ... before descending rather than issue one recursive root listing, which on a git-backed mount would walk `.git/objects` into the cap.

## 3.11.0

### Minor Changes

- [#607](https://github.com/skaile-ai/workspaces/pull/607) [`6a343ba`](https://github.com/skaile-ai/workspaces/commit/6a343ba6902bfed7157b1876f874098579dea038) Thanks [@Frozen666](https://github.com/Frozen666)! - `FinishedEvent` (and the bridge `result` event) gains three optional usage fields: `modelUsage` — the turn's token counts and cost keyed by model id, subagent calls included, as `ModelTokenUsage` (new export from `@skaile/workspaces/types`); `contextTokens` — the prompt size of the turn's last main-thread API call, i.e. the real context fill; and `contextWindow` — the main model's window. `tokens` keeps its meaning (main thread only), so `sum(modelUsage[*]) − tokens` is the subagent share that was missing from every ledger row.

  The claude-sdk driver keeps the SDK result's whole `modelUsage` map instead of only its first `contextWindow`. That map is run-cumulative in the CLI, like `total_cost_usd`, so the driver emits per-model deltas against the previous result of the same SDK run (`diffModelUsage`), resetting on a fresh query spawn exactly as the cost delta does. Consumers that ignore the new keys keep working unchanged. `contextWindow` is keyed by the last main-thread assistant message's model, not the map's first entry, since the map also holds subagent and housekeeping models. The fields ride only the `success` result: a turn that ends in `error_max_turns` or `error_during_execution` becomes a normalized `error` event that carries no usage, the same way `costUsd` and `tokens` are dropped there today.

## 3.10.0

### Minor Changes

- [#600](https://github.com/skaile-ai/workspaces/pull/600) [`547a7ca`](https://github.com/skaile-ai/workspaces/commit/547a7ca91025a2f99043833cd6c9e579bce5909a) Thanks [@henkbla](https://github.com/henkbla)! - Approving an escalated `check` node is now a human override: `applyApproval` accepts `"approved"` when given a non-empty reason, completes the node so the flow advances, and records the decision without rewriting the observation (`CheckEvidence.passed` stays `false`); `"rejected"` remains terminal. The orchestrator prompt now describes a decided check the way it already describes a decided gate, so the agent no longer calls `complete_node` on a node that rejects it.

  The override is refused when a downstream node consumes the check's declared `contract.output.fields` and the failed attempt does not satisfy them — completing the node there would write an execution that fails to hydrate. A later turn keeps the override visible through a durable "Checks completed by human override" prompt section, and a reason-less override replayed through `hydrate`'s `pendingDecisions` reports `payload_invalid` rather than `not_parked`.

## 3.9.2

### Patch Changes

- [#602](https://github.com/skaile-ai/workspaces/pull/602) [`61af37e`](https://github.com/skaile-ai/workspaces/commit/61af37e1df16d62ff92c2253233b4ffc53e5b0f5) Thanks [@peteralbert](https://github.com/peteralbert)! - Agent sessions on a claude.ai subscription seat no longer inherit the seat account's claude.ai connectors (Microsoft 365, Gmail, Google Drive / Calendar, Adobe, …), which were advertised to sessions in unrelated tenant orgs as `mcp__claude_ai_<Name>__*` tools. Two independent switches: the runner merges `disableClaudeAiConnectors: true` into the session's `~/.claude/settings.json` (wherever a credentials file is written: initial provisioning and every token refresh), and the claude-sdk driver sets `ENABLE_CLAUDEAI_MCP_SERVERS=false` on the SDK child env on every auth/cloud path, last, so nothing in `config.env` can re-enable it. The env flag is honored by every Claude Code CLI; the settings key needs Agent SDK 0.3.258 or newer (older CLIs ignore it), so run the agent image on that SDK or newer for both switches to be live.

## 3.9.1

### Patch Changes

- [#597](https://github.com/skaile-ai/workspaces/pull/597) [`9e28c62`](https://github.com/skaile-ai/workspaces/commit/9e28c624d1a4b127e54e537596dc3f4701901f88) Thanks [@peteralbert](https://github.com/peteralbert)! - Scope exposed Git credentials to full repository URLs so connectors for distinct repositories under one owner can coexist, while canonical alias checks preserve duplicate-repository collision protection. Align checkout origins with their helper scopes, surface incomplete private-submodule updates, and always remove exposed credentials after a session-close attempt.

## 3.9.0

### Minor Changes

- [#592](https://github.com/skaile-ai/workspaces/pull/592) [`cf74a3d`](https://github.com/skaile-ai/workspaces/commit/cf74a3d5f92fe945e7181e36d8f3170ab60b0ac3) Thanks [@peteralbert](https://github.com/peteralbert)! - protocol: say which rejection drove a `retry-401` on `host.refresh_credential`

  `reason: 'retry-401'` collapsed two situations the platform has to repair differently.
  Either the credential was rejected — rotate this seat's token — or the seat hit an
  upstream usage limit, in which case the credential is fine and the only repair is
  re-resolving the session onto a peer seat. Nothing on the wire distinguished them, so a
  limit-blocked session pinned to an `AIProviderConfig` that is not opted into
  `autoActivate` never re-resolved: the platform saw a credential problem, rotated a
  perfectly good token, and left the session sitting on the exhausted seat.

  The `ai-credentials` input gains an optional `rejection: 'auth' | 'usage-limit'`
  (`CredentialRejection`, exported from `@skaile/workspaces/types`), attached only alongside
  `reason: 'retry-401'`. Protocol `3.9.0 → 3.10.0`.

  It is set only where the runner genuinely knows. `ClaudeSdkDriver` makes auth and limit
  recovery mutually exclusive for a given failure, and each hook has exactly one way in:
  `onAuthError` is reachable only from an `AuthError`, so it sends `'auth'`; `onLimitBlocked`
  is reachable only from a seat-capacity classification, so it sends `'usage-limit'`. The
  two call sites that classify nothing — the connector token mediator, which forwards a
  `reason` the git driver picked because a credential-helper script touched a flag file, and
  the MCP reauthorizer, a different credential domain entirely — send no `rejection` and
  emit exactly the bytes they did before.

  **`auth` is not a claim that capacity is fine.** `error-classifier.ts` records that
  Anthropic mislabels quota exhaustion on Claude Max OAuth subscriptions as
  `authentication_error`, so `'auth'` asserts only that the rejection was auth-shaped. Only
  `'usage-limit'` is a positive assertion, and only it unlocks new platform behaviour — the
  fallback for `'auth'` is today's, so a mislabel costs nothing new. Consumers must treat an
  unrecognised value as if the field were absent, which is what lets a third rejection be
  added later without another breaking change.

  The single-flight re-pull in `ai-provider-switch.ts` learned the field too. Coalescing now
  ranks requests — `refresh` < bare-or-`auth` `retry-401` < `usage-limit` `retry-401` — and an
  in-flight re-pull may be adopted only by a request asking for no more than it does, because
  adopting a round trip that never mentioned a limit would report success while the session
  stayed parked. This is forward-compat, not a live fix: no caller produces a rank-1 re-pull
  today (`onAuthError` mints directly rather than through `repull`), so in-flight entries are
  only ever rank 0 or rank 2, and the pre-existing `refresh`/`retry-401` rule is a strict
  special case of the rank. It is there because the type admits an `auth` re-pull and the next
  caller through that seam will make one.

  Additive in both directions. There is no peer-version gate, so a 3.9 platform does receive
  the key on those two inputs — compatibility rests on a handler ignoring an unknown input
  key, exactly as it already does for `rejectedFingerprint`, not on the key being absent. See
  root `MIGRATION.md` for the wire contract and the one thing to check (strict input
  validation).

### Patch Changes

- [#595](https://github.com/skaile-ai/workspaces/pull/595) [`af0ca25`](https://github.com/skaile-ai/workspaces/commit/af0ca251faf70068d85eb096031c4bf2400b8e19) Thanks [@peteralbert](https://github.com/peteralbert)! - claude-sdk: stop deleting a successful turn's answer when it merely discusses authentication

  `ClaudeSdkDriver` drops the Claude CLI's own render of an upstream 401 on the promise that the credential self-heal will retry and re-render the real content. That suppression gated on `classifyClaudeSdkError`'s verdict alone — and the classifier buckets a bare `401`, `403`, `unauthorized` or `api key` as `auth`. An ordinary model answer about authentication ("the endpoint returns 401 Unauthorized when the bearer token is missing") therefore qualified, was dropped, and — when the turn then ended with a normal `{subtype:"success"}` result — no `AuthError` was ever built, no retry fired, and nothing re-emitted it. The user saw an empty turn with their answer deleted.

  Both suppression sites now require the same CLI marker the stash already required (`Failed to authenticate`, `API Error:`, `authentication_error`, `not logged in`, `please run /login`, `OAuth authentication is currently not supported`) in addition to the classifier's verdict, via one shared predicate. No markers were added: every CLI auth render observed in this repo — the production rotation 401, `Not logged in · Please run /login`, and the OAuth-unsupported branch — already carries one, and the classifier's wide keyword bucket is the only auth path with no marker. Note that `textIsAuthRender`'s `auth`/`401`/`login` prefilter runs ahead of the classifier, so an `API Error: 403 {…"permission_error"…}` render (and a bare `Not logged in`, which has no `login` substring) never reaches the classifier at all and is not suppressed either way — pre-existing, untouched here, and narrowing rather than widening the effective surface.

  **The streamed site had to move with the message site**, not as a symmetry preference. `normalizer.ts` skips a `message_start` whose text already streamed (`hasStreamedText`). Had the stream kept suppressing on the classifier alone while the assembled message required a marker, benign auth prose would have streamed its pre-keyword prefix, dropped every delta from the keyword onward, and then had its full-text `message_start` discarded by the normalizer — leaving a truncated answer instead of a whole one. The shared predicate also folds in the self-heal-availability check that only the message site performed, so a stream can no longer drop text nothing will ever retry.

  **[#589](https://github.com/skaile-ai/workspaces/pull/589)'s stash is preserved: every genuine CLI 401 that fed the result path still does, and nothing new does.** `stashSuppressedAuthRender` is reachable only from these two sites and independently re-checks availability and the marker; both sites now compute `available ∧ marker ∧ classifier` (the message site additionally excluding tool calls, as before), which is the conjunction the stash was already applying. On the whole-message path the stashed set is therefore identical. On the streamed path it is _narrowed_ by exactly one class, because suppression there is a latch: the old code could combine a classifier verdict taken on an early prefix with a marker that only arrived later, even when the verdict had since moved off `auth`. Verified against the live classifier — `"Request returned 401. "` is `auth`, but `+ "Rate limit exceeded. API Error: retry later."` is `rate_limit` — the old code stashed that text, the new code requires both conditions on the same text and does not. That is the desired direction: it is rate-limit-shaped text that would otherwise have built an `AuthError` and spent a credential refresh. `stashSuppressedAuthRender`'s own guards are kept as defence in depth for a future third call site.

  The bounded grace timer from [#498](https://github.com/skaile-ai/workspaces/pull/498) is untouched, and unchanged behaviourally: benign auth prose was never stashed, so a detail-free `is_error` result after it already classified `unknown` and already armed the timer — only the message's visibility changes.

  **The cost, stated plainly:** for a genuine render whose marker arrives _after_ the classifying keyword, the transient stream prefix shown before suppression engages widens from "text before the keyword" to "text before the marker". That is zero-width for every render observed in this repo — `Failed to authenticate…`, `Not logged in · Please run /login`, and `OAuth authentication is currently not supported` all lead with their marker — and the change simultaneously removes a strictly worse case, where benign prose streamed a prefix and then had its `message_start` swallowed by the normalizer. Showing a moment of a 401 that is about to be recovered beats deleting a real answer.

## 3.8.1

### Patch Changes

- [#589](https://github.com/skaile-ai/workspaces/pull/589) [`14178d0`](https://github.com/skaile-ai/workspaces/commit/14178d0f427d1b8480f7ea370494dfe1192f532c) Thanks [@peteralbert](https://github.com/peteralbert)! - claude-sdk: classify an empty-`errors[]` `is_error` result from the auth render the same turn suppressed

  When the Claude Agent SDK renders an upstream 401 as assistant text, `ClaudeSdkDriver` drops that text on the promise that the credential self-heal will retry and re-render the real content. On the production rotation shape — `{type:"result", subtype:"success", is_error:true, errors:[], terminal_reason:"completed"}` with no follow-up iterator throw — the self-heal was unreachable: `handleResultErrorPayload` called the classifier with an empty `errors[]`, which can only answer `unknown`, so no `AuthError` was ever constructed. The turn's only user-visible evidence had already been discarded, and the turn died with a detail-free failure.

  The result path now falls back to the suppressed render's text when `errors[]` is empty, so the render and result paths reach the same verdict on the same text via the same classifier. A populated `errors[]` stays authoritative and is unchanged.

  The fallback is deliberately narrower than suppression itself, because a wrong verdict here refreshes a credential, bumps that seat's health counters and replays the turn rather than merely hiding a message. Text is carried forward only when the self-heal is actually available _and_ it carries a marker the CLI itself emits (`Failed to authenticate`, `API Error:`, `authentication_error`, `not logged in`, `please run /login`, `OAuth authentication is currently not supported`) — so a model answer that merely discusses 401s cannot trigger a refresh, while the production render matches. Within that set a false positive costs at most one retry, which re-renders content the old behaviour dropped outright.

  The bounded grace timer remains the backstop for genuinely detail-free failures, including an auth-shaped render that an authoritative `terminal_reason` outranks.

- [#587](https://github.com/skaile-ai/workspaces/pull/587) [`31ab187`](https://github.com/skaile-ai/workspaces/commit/31ab18757d81bc855132b4ca8c3263be84695b76) Thanks [@peteralbert](https://github.com/peteralbert)! - claude-sdk: bound the wait for an `is_error` result's follow-up throw so the turn always settles

  When the Claude Agent SDK reported `{type:"result", subtype:"success", is_error:true, errors:[]}` and the classifier returned `unknown`, `ClaudeSdkDriver` deferred the settle to the consumer-error catch — which only runs if the SDK's iterator subsequently throws. That throw is not guaranteed (observed in production when a seat OAuth rotation revoked the prior access token and the next turn 401'd), so `prompt()` could stay pending forever, wedging the prompt FIFO until the 30-minute stall watchdog fired.

  The deferral is kept — the follow-up throw carries the richer classification, and with it the 401 self-heal — but is now capped by a grace timer (default 5 s, override with `SKAILE_ERROR_RESULT_GRACE_MS`). On expiry the turn is failed with a plain `Error` carrying the classified retryable detail, never an `AuthError`. Behaviour when the throw does arrive is unchanged.

## 3.8.0

### Minor Changes

- [#582](https://github.com/skaile-ai/workspaces/pull/582) [`befe90c`](https://github.com/skaile-ai/workspaces/commit/befe90cf4bc0981639ece7defffeb2504404394d) Thanks [@peteralbert](https://github.com/peteralbert)! - fix(connectors): tell the agent — and git — when a required mount failed to connect

  A git connector whose backend credential cannot be minted (an `auth: backend`
  provider link with no refresh token) threw out of `connect()`. The connector then
  never entered the manager's active map, so every agent-facing surface read from
  that map showed nothing at all: no prompt row, no warning, no tool entry. The
  agent went on believing the path was a backend-managed git worktree while the
  whole lifecycle automation — auto-commit, push-after-commit, push-on-hibernate,
  commit-on-hibernate, push-on-close — ran into the void. Because the mount also
  never reached `exposeManagedCredential`, no credential helper was registered for
  the host, so `git push` failed with `fatal: could not read Username for
'https://github.com'` while `git fetch` on a public remote kept succeeding — the
  one failure mode that makes auth look fine.

  Three changes:

  - **The failure is now stated.** `ConnectorManager` remembers connectors that
    failed to connect (`listFailedConnectors()`), and `buildConnectorPromptSection`
    emits a `## Mount failures — read this first` section ahead of `## Mounts`.
    It names the mount and its verbatim error, explicitly supersedes any claim
    elsewhere in the prompt that the path is a managed or synced workspace, warns
    that work written there is neither committed nor pushed, and says the
    credential is minted by the host platform — there is nothing to fix in project
    settings. `connector_list` now returns `{ connected, failed }` on all three
    surfaces — the SDK tool and the MCP and OMP adapters.
  - **git stops with the reason instead of prompting.** A failed git connect
    best-effort registers an "unavailable" credential-helper block. The rendered
    helper writes `skaile: mount not connected: <reason>` to stderr and emits
    `quit=1`, which is the only response that actually stops git's helper chain.
    A stale unavailable block is cleared on the next successful connect — by URL
    prefix, not just by mount id, so a sibling mount that failed earlier under the
    same owner cannot answer a healthy mount's fetch or push — and including for
    Tier-1 mounts, which write no block of their own. The `quit=1` response now
    covers any missing credential, not only a known-dead mount: exiting 0 there was
    what produced `could not read Username` in the first place.
  - **The refresh dance is bounded.** An empty or missing credentials file already
    triggers a refresh request; that request is now rate-limited per mount by a
    cooldown stamp the helper writes after a dance that produced nothing, so a
    refresh that legitimately keeps failing no longer costs every git network op a
    fresh timeout. The driver pre-creates that stamp group-writable
    (`ensureRefreshAttemptStamp`), because `.skaile/` belongs to the runner while
    the helper runs as the agent uid and can only truncate a file that exists.

  Also hardens the rendered helper: every interpolated value (paths, credential
  tag, and the backend-supplied reason text) is shell-quoted, and the reason is
  collapsed to one line.

  Also fixes a shell trap the rendered helper already carried: `:` is a POSIX
  special built-in, so a redirection failure on `: > "$FILE"` terminates a
  non-interactive `dash`/`ash` outright — `|| true` never runs. Both flag writes are
  now redirected inside a subshell, so an unwritable flag or stamp costs the
  cooldown rather than killing the helper before it can report `quit=1`.

  New exports: `FailedConnectorInfo`, `writeUnavailableMountBlock`,
  `removeUnavailableMountBlock`, `ensureRefreshAttemptStamp`,
  `WriteUnavailableMountBlockOptions`, `RemoveUnavailableMountBlockOptions`;
  `RenderHelperOpts` gains `unavailableReason`, `refreshCooldownSec`, and
  `refreshAttemptPath`.

- [#583](https://github.com/skaile-ai/workspaces/pull/583) [`de36d38`](https://github.com/skaile-ai/workspaces/commit/de36d389f2f0ccf37f5b4203c7a4e4da16452870) Thanks [@peteralbert](https://github.com/peteralbert)! - Switch AI provider config on a live session, and report the 401/429s that motivate it.

  A session pinned to a subscription seat that hits its five-hour ceiling used to sit
  there. The platform's re-resolution was ready and working, but it was reachable only
  through a token refresh: `handleRateLimitEvent` logged the `{"status":"rejected"}`
  telemetry and returned, and `onAuthError` is 401-only. In production on 2026-08-29 a
  session watched its seat get parked, hit the ceiling twenty minutes later, and made
  zero credential calls for the next fifty minutes — while three sibling sessions
  failed over cleanly, purely because their tokens happened to expire on schedule.

  Three paths now reach the same single-flight re-pull, which restashes
  `aiProviderConfigId` and queues a driver restart when the platform mints for a
  different seat:

  - a `credentialRotated` broadcast carrying a `configId`;
  - any mint result carrying a `configId` that differs from the stashed one — so an
    ordinary refresh self-heals with no broadcast delivered at all;
  - a new `AgentConfig.onLimitBlocked` driver callback, fired once per logical turn on
    an upstream usage-limit rejection. A different seat back means the driver respawns
    its CLI and replays the prompt once; the same seat back means the original limit
    error surfaces unchanged. The respawn is the point: a resident CLI caches "blocked
    until <reset>" and short-circuits locally, so rewriting the credentials file alone
    changes nothing.

  Alongside it, `AgentConfig.onProviderResponse` reports observed 401s and 429s, which
  the runner forwards as a new `provider_response_seen` event so the platform can bump
  the owning config's health counters — the cron probe samples auth, never capacity, so
  a seat exhausted in live traffic still read as green.

  Protocol 3.8.0 → 3.9.0. Everything is additive and optional in both directions: the
  platform half ships separately and either side works against the other unchanged.

  Two couplings worth knowing, both recorded in `MIGRATION.md`. The seat switch can only
  fire once the platform returns `configId` on the mint — a re-resolution otherwise names
  the new seat nowhere on the wire, so a limit-blocked turn repairs the credentials file
  but cannot know to replay. And the limit re-pull deliberately sends `reason: "retry-401"`
  rather than `"refresh"`: `refresh` is served from the platform's per-config cache without
  ever reaching the resolver, and the resolver is the only place that notices the seat is
  parked.

## 3.7.3

### Patch Changes

- [#577](https://github.com/skaile-ai/workspaces/pull/577) [`b04e8ed`](https://github.com/skaile-ai/workspaces/commit/b04e8edcd48de6e451f287caf01849c68bce91f0) Thanks [@peteralbert](https://github.com/peteralbert)! - Explain the cancellation when a chat reply supersedes an open `AskUserQuestion`.

  The deny message is the entire `tool_result` the model receives — it never sees the
  dialog or the click that did not happen. The old bare reason read as an unexplained
  cancellation, and the model would acknowledge the interruption, find nothing to look
  at, and end the turn without answering the user. The deny now states that the user
  replied in chat and points at the next user turn, which carries their words.

- [#579](https://github.com/skaile-ai/workspaces/pull/579) [`a5cbdda`](https://github.com/skaile-ai/workspaces/commit/a5cbdda41031402e1a825164b52746ab6874b6b5) Thanks [@peteralbert](https://github.com/peteralbert)! - Git connector: bootstrap the declared branch when the remote has no refs at all.

  A GitHub repo created without "Add a README" has **zero** refs — not an empty
  `main`, no branches — so `git clone --branch main --single-branch` could only ever
  fail on it with `fatal: Remote branch main not found in upstream origin`. The
  required connector then failed and the session came up with an empty workspace.

  `prepareCheckout` now probes with `git ls-remote --heads` before cloning an empty
  target dir. A ref-less remote is bootstrapped in place: `git init`, HEAD repointed
  at the declared branch via `symbolic-ref` (never a hardcoded `main`, and never git's
  own `init.defaultBranch` — a plain `clone` would land on `master`), then, for a
  `read-write` connector, an empty root commit pushed as `origin/<branch>` so session
  branching and publish-on-create have a commit to point at. The root commit is made
  regardless of `access` and only the push is gated on it: an unborn HEAD is not a
  stable resting state, because the next connect routes to `bootstrapFromOrigin` and
  dies fetching the still ref-less remote. A `read-only` connector therefore gets the
  branch locally and writes nothing to the remote. A failed push
  never fails the connect: the session comes up on the local branch and the lifecycle
  hooks publish it later, unless a concurrent session won the race, in which case its
  branch is adopted.

  A populated remote that is genuinely missing the requested branch (a typo, or a repo
  that uses `master`) still fails loudly through the unchanged clone path, as does a
  failing `ls-remote` probe.

  Also hardens the connector's error logging: git puts its whole failing argv in the
  error message, and that argv carries the auth URL, so a live token could reach the
  log store — which persists to disk and forwards to the platform gateway. Both the
  new probe path and the pre-existing `connect preparation failed` path now log a
  copy with the URL userinfo scrubbed, while callers still receive the original error.

## 3.7.2

### Patch Changes

- [#574](https://github.com/skaile-ai/workspaces/pull/574) [`74b7498`](https://github.com/skaile-ai/workspaces/commit/74b7498dd17809aa82d126f3482ff14bfcc72e16) Thanks [@peteralbert](https://github.com/peteralbert)! - claude-sdk driver: emit per-turn cost, not the SDK's run-cumulative total ([#573](https://github.com/skaile-ai/workspaces/issues/573))

  `ClaudeSdkDriver` emitted the Claude Agent SDK's `total_cost_usd` straight through on the `result` event's `costUsd`. That field is cumulative over the whole SDK run, not per-turn — every consumer (including the platform's `workspace.usage_record.cost_usd`) treated it as per-turn, so `SUM(cost_usd)` over a session was wrong in both directions: it over-counted long-lived sessions and under-counted ones split across many short runs. Same class of bug as [#551](https://github.com/skaile-ai/workspaces/issues/551), which fixed the analogous issue for token usage.

  The driver now tracks the previous cumulative total and emits the delta between it and the newest `total_cost_usd` as `costUsd`. A decrease in the cumulative total (a fresh SDK session, e.g. after `resetSession()` or a resume that restarts the SDK's own counter) is treated as the new total being the delta outright, rather than going negative. An error result's cost still advances the internal tracker so it isn't silently folded into the following turn's delta, even though error `result` events still carry no `costUsd` field themselves.

## 3.7.1

### Patch Changes

- [#569](https://github.com/skaile-ai/workspaces/pull/569) [`cd1942d`](https://github.com/skaile-ai/workspaces/commit/cd1942dc3236715e91ed3fac415a0ce87bf08bc7) Thanks [@Frozen666](https://github.com/Frozen666)! - claude-sdk driver: migrate to streaming input mode and add a first-SDK-message stall tier ([#566](https://github.com/skaile-ai/workspaces/issues/566))

  The query is now constructed with a persistent pushable `AsyncIterable` prompt instead of a plain string. A string prompt made the SDK mark the query single-turn and close the CLI's stdin at the first result; a CLI kept alive past that close by live `run_in_background` tasks then silently dropped the next prompt, wedging the session for the full 30-minute stall budget. With streaming input mode, stdin stays open across turns and every prompt is pushed into the same live stream.

  The turn-stall watchdog now has two tiers: until the first SDK message of a turn arrives, the new `SKAILE_FIRST_MESSAGE_STALL_MS` budget (default 5 minutes, clamped to the mid-turn budget) applies — total silence after dispatch means the query is deaf, not busy. The existing `SKAILE_TURN_STALL_MS` 30-minute ceiling still governs mid-turn silence (long tool calls).

  Because the CLI's `--max-turns` accounting may accumulate across a resident streaming session, the driver recycles the resident query at a turn boundary once the last result's reported `num_turns` reaches 80% of `maxTurns`; the next prompt respawns with `--resume`.

- [#571](https://github.com/skaile-ai/workspaces/pull/571) [`1634aaa`](https://github.com/skaile-ai/workspaces/commit/1634aaa9868f71796de1ad93bf8bd2038c4cb6ca) Thanks [@peteralbert](https://github.com/peteralbert)! - claude-sdk driver: never settle a turn on a query that did not run it ([#568](https://github.com/skaile-ai/workspaces/issues/568))

  Threshold compaction fired at the right context fill but resolved in the same second with an empty summary in ~85% of attempts. The trigger runs inside the driver's own `result` emit, so the compaction prompt was pushed into a resident query whose generator was already ending; that ending consumer loop's terminal `completeTurn()` then settled the _compaction_ turn instead of the turn it served, resolving it with zero captured text. `validateCompactionOutput` recorded `empty`, which read as a bad model response rather than the infrastructure failure it was.

  A pending turn that has not seen a single SDK message when its serving generator ends is now rejected as retryable and replayed once on a fresh query (`--resume`, so the conversation is preserved) -- unless the caller aborted it, which looks identical from the consumer loop but must never be replayed. The rejection emits no `agent-event: error` — the host must not see a failure the driver is recovering from. Manual compaction, the auth self-heal, the stale-resume retry and the poisoned-transcript retry are unchanged.

  The runner also stops calling `compact("threshold")` from inside the driver's event emit: it is deferred to a macrotask and re-checked at fire time.

  `CompactionAttemptEvent` gains an optional `elapsedMs` (wall-clock ms spent in the compaction `prompt()` call), so an instant `empty` is distinguishable from a genuine empty model response.

  Compaction capture no longer accumulates the same assistant text more than once. The Claude SDK driver reports one message as streamed deltas _and_ `message_start` _and_ `message_end`, so every stored summary was duplicated up to three times.

## 3.7.0

### Minor Changes

- [#564](https://github.com/skaile-ai/workspaces/pull/564) [`51b3594`](https://github.com/skaile-ai/workspaces/commit/51b3594d40307c85ee3f0d87e80898e84c5efb19) Thanks [@henkbla](https://github.com/henkbla)! - flow: never persist a `request_input` schema no host can render

  `request_input` cast the agent's `schema` argument straight into `InputSchema`
  without checking it, so the shapes an agent naturally reaches for — `options:
["Approve", "Reject"]`, or options carrying a label and no `id` — were stored as
  choice gates with undefined option ids. Hosts rendered them as unselectable
  controls and the run parked on a question no human could answer.

  The argument now goes through `coerceInputSchema`, which brings the tolerated
  shapes onto the contract (a bare string becomes `{ id, label }`; a missing id is
  derived from the label, disambiguated by the option index when two labels match)
  and validates the result against the same `InputSchema` object a flow definition
  is validated against. Anything left unanswerable — an unknown `kind`, an option
  or field with no usable label, a duplicate explicit id, an id or label of the
  wrong type, an empty option or field list, a `choice` field with nothing to
  select — is refused at the call site with an issue naming the path and what was
  wrong, and nothing is stored, so the agent can correct the schema and call again.
  A form gate is held to the same bar as a choice gate: its fields are deduped and
  must be answerable. Nothing is ever silently dropped or substituted: each
  unusable entry produces its own issue, and an id the agent wrote is never
  replaced by one derived from the label. Each refusal's issues close with the
  accepted shapes, so any caller's message is actionable without appending them.

  `coerceInputSchema`, `InputSchemaCoercion`, and `INPUT_SCHEMA_SHAPES_HINT` are
  exported from `@skaile/workspaces/factory-assets/connectors/flow/contract` so a
  host normalizing a stored gate on read applies the identical rule rather than a
  second one that can drift.

## 3.6.1

### Patch Changes

- [#557](https://github.com/skaile-ai/workspaces/pull/557) [`ce680d8`](https://github.com/skaile-ai/workspaces/commit/ce680d81235310f45b2597daf8cd2d87efeb5af8) Thanks [@jiradaherbst](https://github.com/jiradaherbst)! - Start the agent driver once, and only ever wire the driver that was started.

  Two races met in `serve.ts`'s lazy driver start, and each one silently kills a
  session. Full rationale in
  `_devlog/notes/2026-08-20-driver-start-memoization.md`.

  The start was guarded by a flag raised _after_ `await driver.start()`. Command
  dispatch is fire-and-forget and every prompt parks on the `sessionReady` gate
  while a cold container builds, so parked prompts release together, all read the
  flag as `false`, and each attaches its own `agent-event` forwarder. Nothing but
  `swapDriver` removes a listener, so the container emitted every later turn once
  per racer for the rest of its life; the platform, which buffers text per
  container with no turn id, merged the copies into one interleaved message row.

  The start is now memoized, so racers await a single start and exactly one
  forwarder is attached, and it snapshots the driver it started. `swapDriver` is
  held off only by the turn tail, and turns are marked after the start returns —
  so a recreate can land inside a pending start. A closure re-reading the outer
  binding would then wire the forwarder onto the incoming driver and mark it
  started without ever starting it, leaving a session that answers nothing. The
  superseded start now commits nothing and the caller re-enters against the
  current driver, bounded so repeated swaps fail loudly instead of spinning.

  The duplicated copy of this logic in `ensureFlowDriverStarted` delegates rather
  than repeating it.

## 3.6.0

### Minor Changes

- [#558](https://github.com/skaile-ai/workspaces/pull/558) [`abfb5b5`](https://github.com/skaile-ai/workspaces/commit/abfb5b51d82d70f546c55bfeb2c9cee0c4c22bfc) Thanks [@henkbla](https://github.com/henkbla)! - Let a flow declare which input field labels a run. A host showing a list of runs
  today has only the opaque instance id to identify them with, even though the run
  input almost always carries the subject — an issue key, a client name, a
  document title. The flow author is the one who knows which property that is.

  `FlowInputContractSchema` gains an optional `labelField`: a single property name
  of the same contract's input `schema`. It lives on the input contract, beside the
  schema it points into, rather than on a run group or on `meta`, so every run of
  the flow labels the same way — including a run started outside a run group — and
  the reference cannot drift from the schema it names. The input contract is a
  strict object, so registering the key is what makes it expressible at all; an
  unregistered key was rejected here and would have been dropped in silence
  downstream.

  A name absent from the declared input schema's `properties` is an authoring error
  and is rejected at parse time with its authored path, reusing the walker that
  already validates declared output-field references. The key is optional and its
  absence stays valid — a host with no declaration falls back to the instance id.

  It is a property name, not a template or an expression: a template would need an
  evaluator on every consumer, and the bounded expression grammar is deliberately
  scoped to admission decisions, not presentation.

  The published JSON Schema (`flow.v2.schema.json`) describes the key, and it is
  part of the canonical definition hash — two definitions differing only in
  `labelField` have different content identities, so a change to the label
  invalidates caches and pins as any other content change does.

## 3.5.0

### Minor Changes

- [#554](https://github.com/skaile-ai/workspaces/pull/554) [`d98cd9d`](https://github.com/skaile-ai/workspaces/commit/d98cd9d3dfece1545c2b93ee1ed372dcf4cc8162) Thanks [@peteralbert](https://github.com/peteralbert)! - Emit the orchestrator prompt as a `flow_prompt` event so hosts can show what the
  agent was actually asked. Every flow turn rebuilds the full
  `# Flow Execution Context` prompt and hands it to the driver; nothing about it
  ever reached the event stream, so a host consuming the stream saw the agent
  replying to input it could not read.

  A new persistent `FlowPromptEvent` (`type: "flow_prompt"`) joins the `AgentEvent`
  union carrying the verbatim `prompt`, the run/flow identity (`runId`, `flowId`,
  `flowName`), the stimulus kind plus its telemetry label, and the `nodeId` for the
  three node-scoped stimuli. The prompt goes out unredacted and untruncated — it is
  the same string the driver receives, not a re-render — because a host that
  rendered anything else would be showing input the agent never saw. The stimulus
  payload (user text, node input) is not repeated on the event: it is already
  inside `prompt`.

  All four orchestrator-turn call sites now route through one
  `driveOrchestratorTurn` helper that builds the prompt, emits the event, then
  awaits the turn — the stimulus bus and the inline sub-flow runner, in the
  runner's `serve` (hosted sessions) and in `runFlow`/`resumeFlow` (CLI runs). The
  helper lives with the flow connector rather than the runner because the CLI entry
  is one of its callers and `factory-assets` must not statically import `runner`.
  An inline sub-flow turn announces the **child** flow's `flowId`/`flowName`. Turns
  that never happen (gated off, or a run already done/failed/cancelled) announce
  nothing, and subprompt nodes — which run on their own isolated driver — are
  unchanged.

  Purely additive to the event union, but the event is non-transient: hosts start
  persisting a multi-KB row per orchestrator turn without opting in. See
  `MIGRATION.md` for what that means for renderers and storage.

## 3.4.2

### Patch Changes

- [#551](https://github.com/skaile-ai/workspaces/pull/551) [`9f46fdf`](https://github.com/skaile-ai/workspaces/commit/9f46fdf89fc6c2945b00099eeabdbb997b3df9d7) Thanks [@peteralbert](https://github.com/peteralbert)! - Compaction now measures context fill from the per-call prompt size instead of the
  turn-cumulative usage.

  `CompactionOrchestrator` derived both the threshold denominator and `tokensBefore` from
  `getTokenUsage()`, whose Claude SDK `result` usage accumulates every API call in a turn —
  overstating the real context by 10-40x on a multi-tool turn (a prod turn reported 3.9M
  cache-read tokens against a 200k window). That fired threshold compaction after every turn
  and made the `poor_ratio` gate discard valid summaries.

  `AgentDriver` gains `getContextTokens()`, returning the prompt tokens occupying the window as
  of the most recent API request (`null` by default). `ClaudeSdkDriver` implements it from each
  `assistant` message's own `usage`, ignoring all-zero synthetic messages, and clears it on
  session reset. The orchestrator prefers it and falls back to `contextTokens(getTokenUsage())`
  for drivers that do not report one, so Codex and other drivers are unchanged.
  `getTokenUsage()` itself is untouched — the `finished` event's `tokens` payload still carries
  the turn total.

## 3.4.1

### Patch Changes

- [#547](https://github.com/skaile-ai/workspaces/pull/547) [`28d6d27`](https://github.com/skaile-ai/workspaces/commit/28d6d27d8504e751e60a4a6dbdbf013de6fedc6c) Thanks [@peteralbert](https://github.com/peteralbert)! - Count cached prompt tokens toward context fill, so automatic compaction can
  actually fire. `CompactionOrchestrator.shouldCompact()` derived fill from
  `usage.inputTokens` alone, but on a cached Anthropic conversation `input_tokens`
  is ~2-10 while the real context sits in `cache_read_input_tokens` — fill computed
  as ~0.005% and the 80% threshold was unreachable. The same field fed
  `tokensBefore`, so a manual compaction's real summary was compared against a
  ~10-token baseline and discarded as `poor_ratio`.

  Both now read a new exported `contextTokens()` helper:
  `max(inputTokens, cacheReadTokens) + cacheCreationTokens`. The `max` rather than a
  sum is deliberate — Anthropic reports fresh input separately from cache reads, but
  OpenAI's `inputTokens` already includes them, and summing would double-count that
  driver into permanent compaction.

  A driver that reports no context window now falls back to a 200_000 default
  instead of returning `false`: a missing window silently disabling compaction is
  strictly worse than compacting somewhat early. A real per-model window table is
  follow-up work.

  Also removes the dead `compaction.compactOnHibernate` config key — nothing reads
  it and no code path invokes `compact("hibernate")`. The `CompactionTrigger` union
  keeps `"hibernate"`, so hosts calling `compact("hibernate")` are unaffected. See
  `MIGRATION.md`.

## 3.4.0

### Minor Changes

- [#540](https://github.com/skaile-ai/workspaces/pull/540) [`876318e`](https://github.com/skaile-ai/workspaces/commit/876318e3cf0e69249b61f95725509e8f7725f507) Thanks [@peteralbert](https://github.com/peteralbert)! - Abort a `function`/`check` node's spawned process when its `control.timeoutSec`
  fires. `NodeExecutor.execute` gains an optional second parameter carrying an
  `AbortSignal`; `SessionNodeExecutor` honors it by killing the spawned process
  _group_ with `SIGTERM`, escalating to `SIGKILL` after a 2s grace, and settling
  only once the child has exited. The drain then gates the next deterministic
  spawn on that settlement, because `abort()` only _starts_ a kill — without the
  gate a retry is spawned during the escalation grace and, for a command that
  traps `SIGTERM`, all `N + 1` attempts run at once. Previously the timeout only
  stopped the adapter _waiting_: with `control.retries: N` up to N+1 orphaned
  processes could run at once, all producing results guaranteed to be discarded.

  The seam change is additive. An executor that declares only `(request)` still
  satisfies the interface and keeps today's behavior — the node still fails on
  timeout and a late settlement is still rejected; only the abandoned work goes
  unreaped. One behavioral note for such an executor: because the retry gate waits
  for the aborted attempt to settle, an executor that never honors the abort now
  delays its own retry by that bounded budget. A timed-out node with no retry left
  is never waited on, so a hung executor still cannot stall the drain's return.

  Deterministic nodes now spawn `detached`, which is what makes the kill reach the
  command's own children; the executor group-kills anything still live at process
  exit to compensate for the child no longer inheriting the runtime's group
  signals. Timeout computation, retry budgeting, and late-settlement rejection are
  unchanged, and the `subprompt` seam still has no cancellation.

### Patch Changes

- [#545](https://github.com/skaile-ai/workspaces/pull/545) [`3ab5535`](https://github.com/skaile-ai/workspaces/commit/3ab553531f2c0300e3a32df22e703936a86ad5b2) Thanks [@peteralbert](https://github.com/peteralbert)! - Deliver watched workspace changes that arrive while an agent is idle as one
  bounded, escaped digest on the next agent-visible message. The digest is never
  persisted as user-authored chat, active-turn file events are suppressed, and a
  failed client send restores pending changes for the next eligible delivery.

- [#543](https://github.com/skaile-ai/workspaces/pull/543) [`12c03ca`](https://github.com/skaile-ai/workspaces/commit/12c03ca69f632a704afe8efcc9378c085a4e9fa4) Thanks [@peteralbert](https://github.com/peteralbert)! - Make `resolveFlowPath` accept every flow file discovery accepts, so a flow
  `skaile flow list` shows is one `skaile resume`, a sub-flow `run.flow` target and
  `skaile serve` can actually find. `resolveFlowPath` had its own hard-coded
  candidate list — `.flow.yaml` plus a legacy bare `.json` under `ai-assets/` — and
  its own root walk, while discovery had grown to `.flow.yaml` / `.flow.yml` /
  `.flow.json` / `.json` across flat and per-flow-directory layouts and every
  content root. Nobody widened the second list when the first grew, so
  `.skaile/flows/<id>/<id>.flow.json`, `.skaile/flows/<id>.flow.json` and any
  `.flow.yml` reported `Flow not found: <id>` for a file that was present, parsed,
  and had been listed one command earlier.

  Both surfaces now go through one definition: `flowLookupRoots` (root policy) and
  `findFlowFileInRoots` (filename policy), exported from the flow engine. The root
  policy itself moved down into `@skaile/workspaces/core` as `aiResourceRoots` /
  `skaileHomeDir` / `resolveLibraryDir`, because both layers that need it already
  depend on `core` while a `factory-assets → cli` import would be a module cycle.
  `@skaile/workspaces/library` and the CLI's `paths` module re-export the same
  symbols, so no public API changed.

  Two behaviour changes worth knowing:

  1. **Resolution is by declared id, not filename.** `resolveFlowPath` used to
     accept `<id>.flow.yaml` on its name alone and let `loadFlow` throw afterwards.
     It now matches the declared `id`, like `run` and `flow list` already did. A
     file whose declared id disagrees with its name is reported by path and
     declared id instead of resolving — and `Flow not found` now names the roots
     searched, the accepted filenames, and any such near miss.
  2. **`resume` / sub-flow targets / `serve` reach the same roots `run` does** —
     factory assets, user libraries and the global `~/.skaile/` install root, in
     addition to the project's `.skaile/` and `ai-assets/`. That is the point of
     the fix (resume must find what run ran), not an escalation: those are the
     roots `skaile run` already executes from.
  3. **Root precedence follows `run` too.** `resolveFlowPath` used to probe
     `<project>/.skaile/flows/` before walking up to `ai-assets/`; it now searches
     the authored `ai-assets/` tree first, which is the order `skaile run` has
     always used so a workspace can shadow a built-in flow. For a flow id present
     in _both_, a resumed session therefore executes the authored source rather
     than the deployed, `patches:`-applied copy under `.skaile/` — the same bytes
     `run` would have executed for that id.

  The `isValidAssetName` identity guard and the "Flow path escapes its lookup root"
  containment guard are unchanged; widening the accepted extensions does not widen
  which paths are reachable.

## 3.3.0

### Minor Changes

- [#528](https://github.com/skaile-ai/workspaces/pull/528) [`864fdd4`](https://github.com/skaile-ai/workspaces/commit/864fdd49cf91815a9fd5331594212ef1b469f389) Thanks [@peteralbert](https://github.com/peteralbert)! - Render a predecessor's declared output fields in `build_handoff`, so a downstream agent reading it as context sees the typed answer and not only the prose.

  `build_handoff`'s `## Upstream handoff` block rendered each completed predecessor as `summary` + `artifacts` + `concerns` and never its `output.fields`. A node's structured output was therefore reachable only by a consumer that bound it through an expression (`nodes.<id>.output.fields.<name>`); an agent node that simply read its upstream handoff saw the producer's prose and never its typed answer. This affected every producer kind — a `function` node capturing stdout into `fields` behaved the same way — and Flow v2 `subprompt` nodes made it far likelier to hit.

  Each completed predecessor's block now carries a `Fields:` list between the summary and the artifacts. It is **declared-only**: just the top-level `properties` of the producer's `contract.output.fields`, in declaration order, which is already the only surface a consumer may bind — undeclared keys an agent happened to send stay scratch rather than becoming a second, unvalidated contract. It is **bounded** by `HANDOFF_FIELD_VALUE_MAX_CHARS` (500 characters per value, per field rather than one shared budget so a single huge field cannot erase the rest) and `HANDOFF_FIELD_MAX_COUNT` (20 fields per predecessor). And it is **de-duplicated**: a field is dropped only when the consumer's own bindings both read it and resolved to that exact value, so a binding that merely derives from a field (`${… > 5}`) or reads one leaf of it still gets the field itself rendered rather than silently dropped. Every truncation, cap overflow, and suppression states itself in the block; when nothing renders, no `Fields:` line appears at all.

  The block is gated on `status === "complete"`, matching the binding layer — a skip spreads the prior execution, so a failed-then-skipped optional node can still carry captured output that expressions already refuse to read (`producer node X was skipped and produced no output`).

- [#531](https://github.com/skaile-ai/workspaces/pull/531) [`08b8674`](https://github.com/skaile-ai/workspaces/commit/08b8674f2d5a82e3cbcb26f48680498735dd7855) Thanks [@peteralbert](https://github.com/peteralbert)! - Discover flows that `skaile install` / `skaile add` actually installed.

  `aiResourceRoots()` covered only the `factory-assets` layers and
  `~/.skaile/libraries/`, never the install target the installer writes to
  (`<project>/.skaile/flows/`, `~/.skaile/flows/`). An install reported success and
  produced a flow that `skaile flow list` did not show and `skaile run` could not
  start — it fell through to free-text prompt mode.

  The install roots are now content roots, derived through the same `deployedBase`
  the installer uses so discovery cannot drift from where the bytes land. Order:
  project-local install (the deployed copy carries `patches:`, so it shadows the
  factory original) → factory assets → user libraries → global install. The
  relative order of the two pre-existing layers is unchanged. `skaile flow list`
  and `skaile flow show` gained `--project-dir`, and `skaile run` now passes its
  own `--project-dir` to discovery, so what a project lists is what it can run.

  Every other asset kind (`skill`, `agent`, `prompt`, `contract`) installs into the
  driver's own directory — `.claude/`, `.omp/`, `.codex/` — which the driver reads
  natively and skaile deliberately does not enumerate. `flow` was the one kind
  skaile owns and executes itself; that boundary is now asserted by a test rather
  than left to a comment.

- [#535](https://github.com/skaile-ai/workspaces/pull/535) [`e2668e0`](https://github.com/skaile-ai/workspaces/commit/e2668e0d214b8d2d28cecc7b5329c2ae0bd8b056) Thanks [@peteralbert](https://github.com/peteralbert)! - Fix `skaile validate versions` silently skipping skill-version pins on v2 flows.

  Both `validate versions` and `flow show` filtered nodes on the legacy `node.type === "skill"` /
  `node.data` shape, which strict v2 flows do not have — v2 pins ride in `run.assets` as
  `skill:<identity>#<range>`. `validate versions` therefore found zero pinned nodes in every v2 flow
  and never ran the check, so a flow pinning an unsatisfiable range validated clean; `flow show`
  printed `Nodes (0 skills)`.

  Both call sites now read through shared, contract-typed node accessors, newly exported from
  `@skaile/workspaces/factory-assets/connectors/flow/engine`: `nodeRunKind`, `nodeIsInert`,
  `nodeSkillAsset`, `nodeSkillVersionPins`, and `flowSkillVersionPins` (alongside the existing
  `node-shape` accessors, which that subpath now re-exports). `validate versions` loads each flow
  through the normalizing `loadFlow`, and a flow it cannot read is reported as a warning instead of
  being skipped in silence. `flow show` now lists every node with its run kind and any pinned skill,
  not only skill nodes.

  Two consequences for legacy v1 flows, both of which bring the CLI into line with what `skaile run`
  already does:

  - A legacy node that pinned `data.version` **without naming a `data.skill`** no longer registers a
    pin. Normalization can only rebuild a `skill:` ref when the node names a skill, so the runner has
    never enforced that pin; `validate versions` used to report it against the node id and now agrees
    with execution instead.
  - Legacy nodes the v2 runtime cannot execute — canvas `group` containers, authored `gate`s, unknown
    kinds — normalize into permanently-blocked placeholders. `flow show` renders those as `inert`
    rather than showing the `router` kind and `optional` flag normalization gave them, neither of
    which the author wrote.

### Patch Changes

- [#529](https://github.com/skaile-ai/workspaces/pull/529) [`ab74d98`](https://github.com/skaile-ai/workspaces/commit/ab74d9840869e735a59ee2145e8c5a9d32299f75) Thanks [@peteralbert](https://github.com/peteralbert)! - Parse a narrated subprompt reply instead of failing the node, refusing on any structural ambiguity.

  `extractSubpromptJsonPayload` stripped a markdown fence only when the fence wrapped the entire
  reply, so `Here's the classification:\n{"topic":"billing"}` went to `JSON.parse` as-is and failed.
  A subprompt has no conversation to reprompt in, so that parse failure is an ordinary node failure:
  it consumes `control.retries` and then fails the node, or skips it when optional. The cost of the
  tail case was the whole node, not a degraded result.

  When — and only when — the payload used today does not parse, the runtime now scans the reply for a
  single balanced top-level JSON structure and uses that. Every reply that parses today yields a
  byte-identical payload, so nothing that works changes. The scan is **fail-closed** and never repairs
  JSON: a stray or mismatched bracket, a structure still open at end of input, more than one candidate
  value, a candidate the sentence continues past (a value being talked about rather than answered
  with), or a sole candidate that does not itself parse all keep today's loud failure. The error names
  the refusal, so a reply that visibly contained JSON is not an unattributable failure.

  The guarantee is structural, not semantic. A refusal whose shape is one JSON value with prose around
  it — a model that answers "I need more information" and prints the schema on its own line — is still
  accepted, and its keys land in `fields`. Telling that from a real answer needs the prose read, which
  this deliberately does not do; a declared contract's validation is the layer that catches it.

  `renderSubpromptPrompt` also restates the object-only directive _after_ the serialized schema, which
  is otherwise the last thing the model reads.

## 3.2.1

### Patch Changes

- [#524](https://github.com/skaile-ai/workspaces/pull/524) [`aa41b03`](https://github.com/skaile-ai/workspaces/commit/aa41b036e4952cf13835f3a51d67f52ca674e485) Thanks [@peteralbert](https://github.com/peteralbert)! - Discover flows in a per-flow directory, and resolve them by declared id everywhere.

  `loadFlowsFromDir` was a single flat `readdirSync`, so a flow at `flows/<id>/<id>.flow.yaml` —
  the natural layout once a flow keeps assets beside it — was invisible to `skaile flow list` and
  `skaile run` with no error. It now also loads `flows/<sub>/<sub>.flow.{yaml,yml,json}` and
  `flows/<sub>/<sub>.json`, where the file must be named after its directory so sibling assets are
  never parsed as flows. A subdirectory holding something flow-shaped that the convention cannot
  reach is now reported on stderr with its path and the expected name instead of dropped silently;
  `_`-prefixed files and directories remain the silent opt-out.

  `findFlowFile` resolved by filename while `flow list`, `flow show`, and the workspace-plugin MCP
  tools resolve by the declared `id`, so a flow whose filename differed from its id was listable but
  not runnable (and runnable under a name that was never listed). Resolution now goes through the
  same enumeration listing uses, keyed on the declared `id`, so the two surfaces agree by
  construction. `.flow.yml` — parsed by `loadFlow` but absent from both discovery filters — is now
  accepted by both.

## 3.2.0

### Minor Changes

- [#520](https://github.com/skaile-ai/workspaces/pull/520) [`04a0b61`](https://github.com/skaile-ai/workspaces/commit/04a0b61fada3da62a78bb1e35e491a7d0155be1e) Thanks [@peteralbert](https://github.com/peteralbert)! - Resolve an optional flow node's exhausted failure to `skipped` uniformly, instead of only for `sub-flow` and `subprompt`.

  `isBlocking` admits a hard `flow` edge only for `complete` and `skipped`, so an optional `agent` (deadline expiry or `fail_node(recoverable: false)`), `function`, or `check` node that ran out of retry budget ended `failed` and blocked every hard-edge dependent forever — a permanent stall at the node the author explicitly marked expendable, which is the opposite of what `control.optional` means. The rule was stated at four terminal-failure sites and implemented at two; all nine now route through one pure `resolveTerminalFailureStatus(node)` in `engine/optional-failure.ts`. `agent`, `subprompt`, `function`, and `sub-flow` are the kinds this can change; `check` routes through the same helper but is inert, because `control.optional` on a check is refused at parse time. `outputHistory` is untouched — every degraded attempt still records `result: "failed"`, so the skip is a compatibility status, not a rewritten history.

  A `router` is the one deliberate exception and still fails when optional. `unsettledRouterBranchIds` excludes a `skipped` router from the gated set while every outgoing hard edge counts as satisfied, so skipping a router admits _all_ of its branches at once — the opposite of selecting one. Optional routers are not hypothetical: v1 normalization maps legacy groups and unknown node kinds onto exactly that shape.

  Two consequences worth knowing before upgrading. A node about to be written `skipped` no longer reports `error.recoverable: true`, and `retryNode` still refuses `skipped` — so an optional `function`/`agent` node that declared `control.retries` loses the manual operator-retry escape hatch it had while it ended `failed`. That is the intended trade: recovery from a skip is deliberately one-way, because a skip has already released the graph (dependents typically become available in the same drain) and `retryNode` owns no cascade or invalidation machinery, whereas a `failed` node still blocks its dependents and leaves nothing downstream to unwind. `skipped` is also not only a failure resolution — `skip_node` and router loser-pruning produce it — so accepting it would quietly turn `retryNode` into an un-skip and a way to resurrect a pruned router branch. `retryNode`'s refusal message now names the skip instead of reporting a generic non-recoverable failure.

  Binding a skipped producer's output is now legible. `FlowExpressionError` gains `skippedProducer`, and a `nodes.<id>.output.fields.<name>` read over a `skipped` producer raises `Flow expression cannot read <path>: producer node <id> was skipped and produced no output` instead of the bare `path does not exist`, which said nothing about the upstream skip. `missingPath` is still set, so router settlement classifies it unchanged. Only `skipped` is narrowed this way — `not_started`, `running`, `failed`, and complete-but-fieldless producers keep the generic message.

  Finally, router settlement no longer waits on a terminal producer: `pendingProducerOutput` treated anything other than `complete` as "not ready yet", so a router reading a skipped (or failed) producer's declared output parked forever. Only a genuinely non-terminal producer is pending now; an unreadable route expression propagates on the path it already took, carrying the message that names the skipped producer.

- [#523](https://github.com/skaile-ai/workspaces/pull/523) [`d5d3c31`](https://github.com/skaile-ai/workspaces/commit/d5d3c31d6e98cedba1556564dc139a7620fc400f) Thanks [@peteralbert](https://github.com/peteralbert)! - Make flow binding provenance transitive, so a passthrough node cannot launder it.

  `BindingOrigin.obtainedBy` was computed one hop from the producing node's kind alone. A
  three-line passthrough `function` node that read an agent's output and re-emitted it as its own
  output relabelled that value `obtainedBy: "runtime"`, and a downstream `check` then reported
  `provenance: "verified"` over it — with `(verified)` shown to the human approver in the escalation
  summary. A value is now runtime-obtained only when no agent-produced value contributed to it
  anywhere in its ancestry, restoring the full claim that provenance is derived and cannot be
  asserted by flow configuration.

  **Persisted shape (additive).** `NodeExecution` and `NodeExecutionSnapshot` gain an optional
  `inputOrigins?: Record<string, BindingOrigin[]>` — the origins resolved for the attempt that
  produced `output`, a latest-pointer that moves with it. `function`, `check`, `subprompt`, and
  `sub-flow` nodes write it in the same mutation that writes `output`; `agent` nodes record it at
  `start_node` for auditability (their output is agent-obtained by kind, so nothing reads it back);
  `gate` and `router` nodes record nothing, because neither resolves bindings and neither emits
  `output.fields` a downstream binding could read. Classification stays one lookup rather than an
  ancestry walk: a producer's persisted origins were themselves classified under this rule when it
  ran, so they already carry any upstream taint.

  **Runs that were mid-flight when the field did not exist** have completed nodes with no
  `inputOrigins`. Absence is read **fail-closed** — as agent-obtained, never as clean — so those
  runs' checks report the weaker `"asserted"` instead of a possibly false `"verified"`. Nothing
  throws, and a run self-heals as its nodes re-run and as new runs start. `{}` (a node that
  genuinely bound nothing) is distinct from absence and reads as runtime-obtained, and a malformed
  persisted entry is read fail-closed rather than thrown on. Hosts that persist and rehydrate
  `FlowExecution` should store the new field; a host that drops it degrades that run's checks to
  `"asserted"` rather than misreporting them.

## 3.1.0

### Minor Changes

- [#518](https://github.com/skaile-ai/workspaces/pull/518) [`d08d42c`](https://github.com/skaile-ai/workspaces/commit/d08d42caf15b21fe9f90f03465bf154745f56efa) Thanks [@Frozen666](https://github.com/Frozen666)! - Expose the tier-1 native-resume outcome on the `session_init` ack. When an init requests a tier-1 resume, the runner runs a deadline-bounded filesystem transcript probe and stamps `resumeProbe` (`transcript_found` | `jsonl_lost`) on `SessionInitAckEvent`, letting the platform synchronously downgrade a lost-transcript resume to a DB-backed restoration before the first post-wake turn. Adds `probeResumeTranscript`, `resolveClaudeConfigDir`, and `ResumeProbeOutcome` exports on `@skaile/workspaces/bridge`.

## 3.0.0

### Major Changes

- [#515](https://github.com/skaile-ai/workspaces/pull/515) [`ff160e9`](https://github.com/skaile-ai/workspaces/commit/ff160e92dc97fce2cfdaae5c75b9add9fc8553dc) Thanks [@peteralbert](https://github.com/peteralbert)! - Remove the inert `control.subagent` and `defaults.subagentMode` fields from the Flow v2 contract.

  Both were carried through v1 normalization while it was open whether a later node kind would consume them; nothing ever read either. They meant "run this skill in a fresh conversation context" — which is what a `sub-flow` already does, not the single isolated model call a `subprompt` performs — so wiring them to `subprompt` would have silently changed what authored flows do.

  **Breaking:** both keys are gone from the strict v2 schema, so a v2 document carrying either is now rejected with an authored path. Only the v1 normalizer ever produced them and it no longer does; a v1 document may still carry them and both are dropped. Dropping `subagent_mode` is explicit rather than a deleted key mapping, because `defaults.parameters` is an open passthrough bag — an unmapped key would have survived as `defaults.parameters.subagent_mode`, a rename rather than a removal.

### Minor Changes

- [#515](https://github.com/skaile-ai/workspaces/pull/515) [`ff160e9`](https://github.com/skaile-ai/workspaces/commit/ff160e92dc97fce2cfdaae5c75b9add9fc8553dc) Thanks [@peteralbert](https://github.com/peteralbert)! - Activate Flow v2 `subprompt` nodes — the last of the seven Flow v2 kinds. A subprompt is `function`-shaped for everything except how the work is performed and how its output provenance is classified: instead of `NodeExecutor`, it hands its interpolated instruction to a `SubpromptRunner`, whose production implementation (`SessionSubpromptRunner`) constructs a second, isolated `AgentDriver` instance per call — same backend, provider, resolved model, and credentials as the session driver, but no Skaile system prompt, no MCP servers, no connector capabilities, no access to the session conversation, and bounded to a single turn. It does retain the backend's own native tool surface rooted at the session workspace — isolation here is a separate conversation, not a sandbox. `run.model` (`"small" | "default" | "deep"`) resolves through a new pure `resolveModelTier` in `bridge/src/models.ts`: a host-configured override first, then `PROVIDER_MODEL_TIERS`, a new built-in per-provider table of direct provider-API ids (consulted only on the direct API, since a gateway transport renames them) whose every entry is pinned by test to also exist in `STATIC_MODELS`. `default` and an absent tier inherit the session model — the one id known to work for this deployment, already carrying whatever gateway translation the host configured; `small` and `deep` instead raise the new `ModelTierResolutionError` and fail the node, so an explicit request for a cheap or a strong model is never silently served by whichever model the session happened to start with. With no declared output contract the response is prose; with one declared, the response is additionally parsed as JSON and validated through the same `validateConsumedNodeOutput` path every kind uses — there is no corrective reprompt, so a parse or validation failure consumes `control.retries` like any ordinary failure. The reply is carried verbatim as the node's `summary` in both cases, since `fields` reaches a consumer only through an expression binding and `build_handoff` renders a predecessor's summary/artifacts/concerns but never its `fields`. `control.optional`, `control.retries`, and `control.timeoutSec` behave as they do for a `function` node, except that an optional subprompt whose retry budget is exhausted becomes `skipped` rather than `failed` — matching `sub-flow`, because only `complete` and `skipped` satisfy a hard `flow` edge, so leaving it `failed` would permanently block its dependents. A subprompt inside an inline `sub-flow` child reaches the same runner as one in the parent. A subprompt's output is classified `obtainedBy: "agent"` (fixing a provenance hole: a downstream `check` consuming it now always derives `"asserted"`, never `"verified"`). `raceWithTimeout` does not cancel the underlying driver call on timeout — a known, documented cost this change does not address.

## 2.9.0

### Minor Changes

- [#511](https://github.com/skaile-ai/workspaces/pull/511) [`90a3476`](https://github.com/skaile-ai/workspaces/commit/90a34763f64b1ca3f22699a1c807ed793c0c14db) Thanks [@peteralbert](https://github.com/peteralbert)! - Enforce Flow v2's `control.optional`, `control.retries`, and `control.timeoutSec` node controls, previously declared in the contract but read by nothing. `retries: N` bounds an automatic retry (first attempt plus N automatic retries) that composes with the existing `doFailNode(recoverable: true)` self-retry and the manual `retryNode` operator override, which remains outside the automatic budget. `timeoutSec` covers active execution only, pausing during approval/input waits; a `gate` node never times out. `function` and `sub-flow` nodes enforce it by racing their existing await; `agent` nodes have no await to wrap, so their deadline is derived from `startedAt` plus recorded approval/input history and re-evaluated by an opportunistic wake timer while connected. Late settlement after a timeout, cancel, hydrate, or start is rejected via `executionGeneration`, now checked consistently at every await site (previously only the sub-flow branch checked it). `NodeExecution` gains an additive `outputHistory: NodeExecutionAttempt[]` — an append-only record of every attempt, numbered from 1 and resuming at `max + 1` after hydration — while `output`/`error`/timestamps remain latest-pointer compatibility fields. A sub-flow node's own automatic retry now actually retries (previously the node could wedge `available` forever). The agent's own recoverable self-retry (`fail_node(recoverable: true)`) also appends an `outputHistory` entry now, marked `NodeExecutionAttempt.selfRetry: true` so it is never counted against the automatic `control.retries` budget. A delegated sub-flow child's own `error.at` is now preserved on the parent's failure instead of being overwritten by the parent's `completedAt`; the child's own `error.recoverable` remains intentionally not propagated — the parent's recoverability continues to follow only the parent node's own `control.retries`.

- [#513](https://github.com/skaile-ai/workspaces/pull/513) [`50d50b5`](https://github.com/skaile-ai/workspaces/commit/50d50b5304bf566490f04612ff3792d1e7034e22) Thanks [@peteralbert](https://github.com/peteralbert)! - Activate Flow v2 `check` nodes. A check runs the same deterministic process as a function node through `NodeExecutor`, but its exit status is a pass/fail verdict: a pass completes dark like a function, while a fail verdict derives evidence (compared values; provenance — "verified" when every compared value's _immediate_ origin was runtime-obtained, "asserted" otherwise, absent when there were no compared values; "live" vs. "flow-local" inputs, "live" being the floor) and, once `control.retries` is exhausted, either fails the node (`run.onFail: "fail"`) or parks it `awaiting_approval` with that evidence (`run.onFail: "escalate"`, the default). Escalation is a one-way door: `applyApproval` rejects an `"approved"` decision on a check outright, and `"rejected"` ends the node terminally `failed` with no revision path. A `check` node cannot declare `control.optional` — rejected at parse time. A spawn failure or timeout produces no verdict and always fails the node directly, with no evidence and no escalation. `NodeExecutionAttempt` gains an optional `check?: CheckEvidence`; `CheckEvidence` and its companion types (`CheckProvenance`, `CheckInputCurrency`, `CheckComparedValue`) plus `BindingOrigin` are now canonical in and exported from `@skaile/workspaces/types`.

## 2.8.0

### Minor Changes

- [#509](https://github.com/skaile-ai/workspaces/pull/509) [`0ed7865`](https://github.com/skaile-ai/workspaces/commit/0ed786515388f26073d538c626e11092ecdf8e4e) Thanks [@peteralbert](https://github.com/peteralbert)! - Admit queued run-group inputs by priority. `InputEnvelope` gains an optional
  `priority`, and `admit()` now orders by priority descending before falling back
  to the existing FIFO ordering on `receivedAt` with an `instanceId` tiebreak.

  An absent priority is treated as 0, so a queue of inputs that never set one is
  admitted in exactly the order it was before — an urgent item can jump ahead
  without any existing caller changing. Priorities are unbounded signed integers,
  so a caller never has to know the range already in use to outrank it. Capacity,
  pause, status and slot-occupancy behaviour are unchanged.

## 2.7.0

### Minor Changes

- [#506](https://github.com/skaile-ai/workspaces/pull/506) [`1f80f44`](https://github.com/skaile-ai/workspaces/commit/1f80f442b73d1bf3803fe496dcca4af4c1520bdb) Thanks [@peteralbert](https://github.com/peteralbert)! - Add an optional `model` tier (`small` | `default` | `deep`) to Flow v2 `agent` and `subprompt` run nodes, narrowing `subprompt`'s prior free-form model id to the same closed vocabulary. `model: "default"` is treated as absent for content-identity hashing.

### Patch Changes

- [#507](https://github.com/skaile-ai/workspaces/pull/507) [`90f9477`](https://github.com/skaile-ai/workspaces/commit/90f9477ab6e72a54c5cd080991a3ddffb86a1b3c) Thanks [@peteralbert](https://github.com/peteralbert)! - `AgentStore.cancel()` no longer flips `phase` straight to `"idle"` before the
  backend confirms the turn actually stopped. It now sets a new `"cancelling"`
  phase, and the store's `finished` handler only forces `"idle"` for a natural
  (non-cancelled) completion — a cancelled `finished` event (the synthetic one
  persisted immediately for reload-durability) leaves phase alone, so it stays
  `"cancelling"` until the later, genuine `status` event reports the driver's
  real abort/kill sequence as done. Consuming UIs can now show a real "stopping"
  state instead of a premature "idle" one.

## 2.6.0

### Minor Changes

- [#503](https://github.com/skaile-ai/workspaces/pull/503) [`e79cb7b`](https://github.com/skaile-ai/workspaces/commit/e79cb7b20faa37bcc95cdd38b3d5289de1e28b50) Thanks [@peteralbert](https://github.com/peteralbert)! - Activate Flow v2 router nodes with ordered bounded expressions, coordinated hard-flow branch settlement, durable selections, and retryable unresolved values.

## 2.5.0

### Minor Changes

- [#500](https://github.com/skaile-ai/workspaces/pull/500) [`5104b98`](https://github.com/skaile-ai/workspaces/commit/5104b980e3e7243f19142079257c5fc42be20722) Thanks [@peteralbert](https://github.com/peteralbert)! - Protocol 3.8 — acknowledge receipt of a prompt

  `prompt` was the only frame in the protocol with no acknowledgement. A host sent
  it into the socket and got nothing back, so a runner that had stopped executing
  while its WebSocket stayed open (an in-container OOM is enough) swallowed every
  subsequent user message with no trace anywhere: no log, no event, no failed
  send. The host had no way to distinguish "delivered, still thinking" from "never
  arrived".

  The `prompt` command gains an optional `messageId`. When it is set the runner
  emits exactly one new `prompt_ack` event echoing it back, as the **first act** of
  `handlePrompt` — before `awaitSessionReadyOrFail()` (which drops prompts
  silently), before the secrets check, and before the driver starts. When it is
  absent nothing is emitted, so a 3.7 host sees byte-identical behaviour, and a
  3.8 host talking to a 3.7 runner must feature-detect on
  `major === 3 && minor >= 8` rather than read the missing ack as a lost prompt.

  The ack is deliberately receipt-only: it says the frame arrived and was decoded,
  never that the turn ran. A driver that accepts a prompt and then leaves the turn
  promise pending is a separate failure mode and needs turn-level supervision.

  `prompt_ack` is transient — system RPC, not chat. It is on the dispatcher's
  denylist so it is never persisted (an unlisted ack would also split the
  assistant's streaming bubble mid-turn).

### Patch Changes

- [#501](https://github.com/skaile-ai/workspaces/pull/501) [`1076360`](https://github.com/skaile-ai/workspaces/commit/107636009ebbbfeb7d82b93a2a8633f84dfc59b6) Thanks [@peteralbert](https://github.com/peteralbert)! - Fix `ClaudeSdkDriver` so aborting a turn (Stop) actually kills an in-flight
  tool subprocess (e.g. a running Bash command) instead of leaving it orphaned.
  `query.close()` only tore down the SDK's connection to the CLI subprocess, not
  whatever process that CLI in turn spawned for the tool call, and a
  process-group signal to the CLI didn't reach it either since the CLI spawns
  the tool's shell into its own new process group. The driver now captures the
  CLI subprocess's own OS pid via `spawnClaudeCodeProcess`, snapshots its
  descendant processes before sending any abort signal (reparenting on exit
  would otherwise make a post-abort snapshot come back empty), and SIGKILLs
  that snapshot from `abort()` and `kill()` alike.

## 2.4.0

### Minor Changes

- [#497](https://github.com/skaile-ai/workspaces/pull/497) [`b282668`](https://github.com/skaile-ai/workspaces/commit/b2826683a48b4075e42e4a86704912cb9f9c8634) Thanks [@peteralbert](https://github.com/peteralbert)! - Add `sub-flow` as the seventh Flow v2 node kind and run delegated flows once,
  inline, through the existing session agent, deterministic executor, gate, and
  FlowAdapter lifecycles. Exact legacy sub-flow nodes now normalize into this
  active shape, and dependency discovery emits duplicate-safe flow requirements.
  Parked child interactions project onto the durable parent node while resumable
  child adapters remain transient in memory; canonical identity, ancestry, and
  depth guards bound delegation without adding child records or state fields.

- [#493](https://github.com/skaile-ai/workspaces/pull/493) [`6bcbbfd`](https://github.com/skaile-ai/workspaces/commit/6bcbbfd5697f5b2528944ff8954d11f28a8e9e48) Thanks [@peteralbert](https://github.com/peteralbert)! - Execute Flow v2 function nodes through the session workspace without starting a model turn, including non-success signal termination.

- [#496](https://github.com/skaile-ai/workspaces/pull/496) [`92da12c`](https://github.com/skaile-ai/workspaces/commit/92da12cd71ccf7b8e393880efa675ccf2c1f994d) Thanks [@peteralbert](https://github.com/peteralbert)! - Run strict Flow v2 gate nodes as mandatory runtime-owned human decisions that park without a model, complete on approval, and preserve rejection feedback for revision and re-request.

- [#495](https://github.com/skaile-ai/workspaces/pull/495) [`39c2da3`](https://github.com/skaile-ai/workspaces/commit/39c2da359591d6727f1b2a843d81dafb43912de0) Thanks [@peteralbert](https://github.com/peteralbert)! - Resolve Flow v2 node bindings with provenance, enforce preconditions, and validate downstream-consumed output fields with one durable agent correction.

## 2.3.0

### Minor Changes

- [#491](https://github.com/skaile-ai/workspaces/pull/491) [`c37f664`](https://github.com/skaile-ai/workspaces/commit/c37f664574c994432f2264768cc7d5d98009cb41) Thanks [@peteralbert](https://github.com/peteralbert)! - Run Flow v2 agent nodes with registry-backed turn tools, first-class instructions, supplied bindings, and reusable asset requirements while preserving normalized legacy flow behavior.

## 2.2.0

### Minor Changes

- [#490](https://github.com/skaile-ai/workspaces/pull/490) [`084d692`](https://github.com/skaile-ai/workspaces/commit/084d6920ff0689758274211dab4518eccf8213be) Thanks [@peteralbert](https://github.com/peteralbert)! - Publish the strict Flow v2 authoring contract with six node kinds, v1 normalization, bounded expressions, generated JSON Schema, and canonical content hashes.

### Patch Changes

- [#488](https://github.com/skaile-ai/workspaces/pull/488) [`2949919`](https://github.com/skaile-ai/workspaces/commit/29499195910d19537c14ebdbefaeeae54a612b01) Thanks [@peteralbert](https://github.com/peteralbert)! - Characterize the current pure flow-state semantics across the set-based and snapshot-based engine entry points.

## 2.1.0

### Minor Changes

- [#485](https://github.com/skaile-ai/workspaces/pull/485) [`255aad2`](https://github.com/skaile-ai/workspaces/commit/255aad2a55d12ace016ef297f4cefb5ed1347ed7) Thanks [@peteralbert](https://github.com/peteralbert)! - Render declared MCP servers into the coding agent's own MCP config

  MCP servers installed with `skaile add mcp-server:<ref>` or declared under
  `mcp_servers:` in `skaile.yaml` were only ever wired into skaile's own agent
  loop (the bridge `claude-sdk` driver). A developer who ran `skaile install` in a
  project and then opened Claude Code in that folder did not get the server — they
  had to hand-write `.mcp.json`.

  `install()` and `add()` on `AssetManager` now also render the
  project's declared MCP servers into the driver target's native project-scope MCP
  config — `<project>/.mcp.json` for `claude-code` — the same way skills already
  render into `.claude/skills/`. The new `renderMcpServerConfig(projectDir,
driverTarget, opts?)` export from `@skaile/workspaces/asset-manager` does the
  work and can be called directly.

  Ownership is explicit and never inferred: ids skaile wrote are tracked in
  `<project>/.skaile/mcp-managed.json`, so a hand-written entry is never
  overwritten or deleted, unknown top-level keys survive verbatim, and a
  declaration that disappears takes only its own generated entry with it. A
  project with no MCP servers gets no files at all.

  Declarations that cannot produce a working entry on a consumer machine are
  skipped with a one-line reason instead of emitting something broken: reserved
  ids, `launcher: recipe` (needs the platform's Nix runtime), `auth: backend`
  (needs a platform-minted credential), a missing `command`/`url`, and any
  `forge:` / `op:` / `kp:` / `oauth:` / `vault:` / `mount:` / `connector:` /
  `mcp:` secret reference. A declaration whose `env` carries resolved credentials
  folded in from a sibling `.instance.json` is skipped too — those are live
  plaintext secrets that must stay in memory, so `McpServerDeclaration` gains an
  internal `instanceSecretsFolded` provenance flag (set by the core resolver,
  never authored in `skaile.yaml`) for disk-persisting consumers to check.
  `env:NAME` refs
  render as `${NAME}` for the agent's own environment expansion, and
  `${workspace}` expands to the absolute project directory. `codex` and `omp` are
  deliberate no-ops until their project-scope MCP config format is verified.

### Patch Changes

- [#486](https://github.com/skaile-ai/workspaces/pull/486) [`5bdf739`](https://github.com/skaile-ai/workspaces/commit/5bdf739bf81dadee8f5801e2ebd06d8ee77304f7) Thanks [@henkbla](https://github.com/henkbla)! - Fix a flow run started while the agent driver is already running never executing: the driver recreate disposed the ConnectorManager the `flow:<runId>` connector and its MCP tool handlers were bound to, leaving the run stuck at `status: running` forever. The recreate now happens before the connector is connected and its tools are built.

## 2.0.0

### Major Changes

- [#470](https://github.com/skaile-ai/workspaces/pull/470) [`208af20`](https://github.com/skaile-ai/workspaces/commit/208af209fb59c18e483e7c3662d1a672d55aa465) Thanks [@peteralbert](https://github.com/peteralbert)! - Never touch the workspace mount synchronously ([#454](https://github.com/skaile-ai/workspaces/issues/454))

  Inside the agent container `/skaile/workspace` **is** the rclone FUSE mount
  (SharePoint / OneDrive / WebDAV), and the long-lived `skaile serve` process was
  reaching it with **synchronous** `fs` and `child_process` calls on per-user-click
  request paths. When the host rclone process wedges, such a call parks the single
  threaded event loop in an uninterruptible kernel wait: the whole runtime stops
  serving and the platform reads the session as dead or idle. **A hang is not an
  error, so the `try/catch` around those calls never fired** — which is why this
  class survived normal review. The same defect caused two total platform outages
  on 2026-08-07 (65 min and 5.5 min); the platform side was fixed in
  skaile-ai/platform#2018, [#2023](https://github.com/skaile-ai/workspaces/issues/2023) and [#2046](https://github.com/skaile-ai/workspaces/issues/2046), and this package carried the remaining
  live CRITICALs.

  Every such call site is now async (`node:fs/promises`, promisified `execFile`)
  and bounded by a deadline, so a wedged mount degrades to **one failed request**
  instead of a dead session. Async-ness is the actual fix — the deadline only
  converts an unbounded wait into a bounded, reportable failure. Node cannot abort
  a syscall already in the kernel, so a losing operation stays pending; that costs
  one handle rather than the event loop.

  New shared primitive `withFsDeadline` / `tryWithFsDeadline` / `FsDeadlineError`
  in `@skaile/workspaces/core`, modelled on the existing `ensureFleetMounted`.

  Also fixed along the way:

  - **Unbounded recursive walks** — the mount resource listing and `walkDir`
    followed symlinks with no depth, entry, time or cycle bound. Both are now
    bounded on every axis, and truncation is always surfaced rather than passed
    off as a complete result. The two differ in _how_, deliberately:
    - The **mount listing throws**. It feeds the platform's folder rename, which
      is list-recursive + copy + delete, so a silently short listing would have
      deleted the un-copied remainder.
    - **`walkDir` / `scanDirectory` report** a structured `WalkTruncation` (default
      sink: a `warn` naming the bound that was hit) and return what they found. A
      catalog scan is advisory — failing the whole scan because one repo is
      pathologically deep would be worse than listing what is there. Callers that
      need certainty should inspect the reported truncations; today `scanRepo`,
      `resolveLibraryEntries` and the catalog/connector listings do not.
  - **`SIGTERM` never lands on a `D`-state process.** Every git/nix/docker
    subprocess timeout now uses `killSignal: "SIGKILL"`, and the call sites that
    had _no_ timeout at all (`connectors/src/worktree.ts`, the deploy connector's
    `rev-parse`) have explicit ones. The pre-existing timeouts bounded the
    healthy-but-slow case only.
  - **Overlapping timer ticks.** The git connector's 60 s fetch, 120 s submodule
    sync and idle auto-commit could stack once each tick began awaiting; ticks now
    skip while the previous is in flight, catch their own rejections (a `void`-called
    async tick would otherwise take the process down), and cannot outlive `disconnect()`.
  - **A live substitution bug**: `resolveRecipePath` became async and its result
    was placed into the MCP recipe map un-awaited, substituting into config as the
    string `[object Promise]`.
  - **`deployAll` now honours its documented contract** — a filesystem failure
    returns `{ success: false, error }` per entry instead of throwing and aborting
    the batch.
  - The git connector's shell-interpolated command strings are replaced by argv
    arrays, removing an argument-injection/quoting class (paths and commit
    messages containing spaces or quotes were previously mis-split).

  This is a **major** release: several exported symbols became async. See
  `MIGRATION.md` for the full `from → to` list and migration steps.

### Minor Changes

- [#467](https://github.com/skaile-ai/workspaces/pull/467) [`be5a346`](https://github.com/skaile-ai/workspaces/commit/be5a346fd03ea621947ddbf3515e66d7494384c6) Thanks [@peteralbert](https://github.com/peteralbert)! - `skaile manage`: lossless, non-blocking arrow-key navigation (coalesced key chunks are split, the soft-dep preview is debounced instead of awaited, and each frame is a single batched write), plus PgUp/PgDn/Home/End paging and an incremental `/` search on the Assets tab that combines with the origin filter.

- [#469](https://github.com/skaile-ai/workspaces/pull/469) [`e4d96bc`](https://github.com/skaile-ai/workspaces/commit/e4d96bced7fde9ac473ec4494f5d13087f6d4d09) Thanks [@peteralbert](https://github.com/peteralbert)! - fix(store): fetch, stage, and wire store-sourced `mcp-server` assets

  `skaile add mcp-server:<store-ref>` was a silent no-op: the dep landed in
  `skaile.yaml`, `skaile list` / `skaile check` reported it installed/satisfied, but
  the bytes were never fetched and the server was never wired into a session.

  `NON_DEPLOYABLE_KINDS` was doing double duty — gating both the `deployAll` copy
  (correct: an MCP is wired at session start, not copied to a driver target dir) and
  the `stageStoreAssets` fetch (wrong: the wiring reads a real `MCP.md` off disk).
  Three changes:

  - `stageStoreAssets` now fetches + SHA-verifies `mcp-server` bytes into
    `.skaile/cache/store/<publisher>/mcp-server/<name>/<version>/`. `persona` /
    `ruleset` / `knowledge` stay unfetched — their resolver only reads clone-derived
    repo maps. Nothing is copied into `.claude/`, so `add()` / `install()` still
    return `[]` for the kind.
  - `loadMcpServerDeclarations()` gained the store cache as a discovery source,
    between catalog-dep refs and platform-materialized assets. Precedence is now
    catalog-dep < store-staged < materialized < explicit `mcp_servers:`; undeclared
    staged dirs are still dropped (canonical-contract invariant).
  - `skaile list` reports a locked `mcp-server` with no resolvable declaration as
    `drift` instead of `installed`, and `skaile check` now fails on any declared-but-
    unmaterialized dep instead of printing "All dependencies satisfied."

  Note: an `MCP.md` whose `command` uses `${recipe:...}` markers still only starts
  inside a platform-deployed session (the Nix out-paths are built there). Staging
  makes the server visible and declared; resolving recipes locally is a separate gap.

### Patch Changes

- [#477](https://github.com/skaile-ai/workspaces/pull/477) [`e253db1`](https://github.com/skaile-ai/workspaces/commit/e253db1ac165767a84aa854db7e28cef456e29e7) Thanks [@peteralbert](https://github.com/peteralbert)! - Enable Biome's `noFloatingPromises` and handle the floating promises it found ([#476](https://github.com/skaile-ai/workspaces/issues/476))

  The repo had **no** lint rule for unhandled promises, so a missing `await` was
  caught only by `tsc` — and only in the cases where the `Promise` was then used
  as something it isn't (`.map`/`.flatMap`). A promise left floating in statement
  position was invisible.

  Nine call sites were floating. Two were latent process-killers and seven were
  harmless-but-unmarked:

  - `bridge/drivers/claude-sdk.ts` — the long-lived message loop now carries a
    terminal handler. Its body is wrapped in `try/catch`, but that catch itself
    emits, which can throw; an unhandled rejection there would take the process
    down. The sibling background fetch is marked `void` instead, since its own
    catch only logs and cannot reject.
  - `factory-assets/connectors/devserver/adapter.ts` — the two stdout/stderr log
    pumps are marked `void`; they run for the process's lifetime and swallow their
    own stream errors.
  - Four test helpers awaited nothing while reading the emitted event
    synchronously. That only worked because `handleMountResourceRequest` happened
    to run synchronously up to its first `await` — a real latent break, not a
    style nit.
  - One e2e test dropped the promise from `unwatchAll()`.

  Scope note: Biome's rule only sees **statement-position** floats. It does not
  catch a promise that is assigned and then misused in a conditional, a
  comparison, or a template — and Biome has no equivalent of ESLint's
  `no-misused-promises` / `no-unnecessary-condition`. This raises the floor; it
  does not close the class. See [#476](https://github.com/skaile-ai/workspaces/issues/476) for the measurements.

- [#468](https://github.com/skaile-ai/workspaces/pull/468) [`74d4e60`](https://github.com/skaile-ai/workspaces/commit/74d4e6060e496137ccf8585f00371eb44b537100) Thanks [@peteralbert](https://github.com/peteralbert)! - `skaile info` now resolves store-sourced assets: the lookup falls back from source clones
  and factory assets to this project's deployed assets (offline) and then to the configured
  catalog source, and the not-found hint no longer tells you to run `skaile source sync` for
  an asset that only exists in the store.

- [#465](https://github.com/skaile-ai/workspaces/pull/465) [`a05f2f2`](https://github.com/skaile-ai/workspaces/commit/a05f2f20702a15167a7c8012d6ea8b8e3fc2d656) Thanks [@peteralbert](https://github.com/peteralbert)! - Fix unpinned store-only assets failing to install with `Asset not found`. The store
  fetcher built by `AssetManager` now implements the optional `listVersions` member of
  the resolver's `StoreFetcher` contract, delegating to `RemoteCatalogSource#listVersions`
  (degrading to `[]` on a store/transport failure, with a warning so an outage is not
  silently reported as a missing asset). Without it a store-published asset
  with no source clone and a bare/ranged ref (`skaile add skill:@admin/xls`) produced no
  candidate versions and landed in `missing`; only an exact pin resolved.

- [#474](https://github.com/skaile-ai/workspaces/pull/474) [`88ee95e`](https://github.com/skaile-ai/workspaces/commit/88ee95e07610e0270a18b18761997b28a7a0dec8) Thanks [@peteralbert](https://github.com/peteralbert)! - Stop `AgentStore._handleEvent` from notifying subscribers on the four remaining structural
  no-ops. `_notify()` invalidates the cached snapshot, so each of these handed every
  `useSyncExternalStore` / Vue `shallowRef` consumer a brand-new snapshot identity for state that
  had not changed.

  The largest was a missing `default:` arm. The switch names 22 of `AgentEvent`'s 54 members; the
  other 32 — `log_entry` above all, one per line of container output — fell through to the trailing
  `_notify()` having mutated nothing. Unlike the cases fixed in the two preceding patches this was
  not a replay artifact: it fired on every single occurrence. Those 32 types are now ignored
  outright. The arm is deliberately not an `assertNever` exhaustiveness check — that is a compile
  error while 32 members are unnamed, and naming all 54 would enumerate events the store holds no
  state for just to satisfy it.

  `state_changed` rebuilt the `sharedState` Map unconditionally, and unchanged re-emits are routine:
  the runner replays every namespace on connect, on a seeded execution, on re-registering an
  already-registered store, and immediately after registration — and the `xstate-store` adapter is
  equality-free, so writing a key its current value emits too. It now compares against the value the
  namespace already holds, reusing the capability comparison generalized into one shared
  `isSameJsonValue` helper: `JSON.stringify` equality with a fail-open `try/catch`, deliberately
  conservative so a genuine state update can never be mistaken for a no-op.

  `protocol_info` reassigned unconditionally, and the runner announces on every attach — tab mount,
  StrictMode remount, SSE reconnect — almost always with the version already held. `ProtocolInfoEvent`
  carries nothing but `version` and `ProtocolVersion` is exactly three numbers, so the field
  comparison is complete, not conservative. When the version is unchanged the held event object is
  kept, so `snapshot.protocolInfo` does not change identity either.

  `user_message` is flush-only, and the flush is internally conditional — a peer message arriving
  while nothing is streaming finalized nothing. `_flushStreaming()` now reports whether it flushed
  and the case notifies only when it did.

  Three behaviour changes consumers can notice, beyond the reduced re-renders. **`protocol_info` now
  clears `incompatibleProtocol`.** Nothing ever cleared it, so the platform's "session restarting on
  incompatible protocol" banner was sticky for the life of the store even though the debug pane
  documents the replacement container's `protocol_info` as the thing that dismisses it; the guard had
  to land together with the clear, or a repeat announcement would never have reached the clearing
  code and the bug would have been cemented permanently. **32 event types no longer notify at
  all**, so a consumer that treated "any event arrived" as a liveness signal must now emit its own —
  hosts that need those events already read them off the transport upstream of the store. And an
  **optimistic `updateState()` write is no longer corrected by the runner's echo** when the two are
  `JSON.stringify`-equal: `updateState()` stores the caller's object verbatim, so a caller passing a
  `Date`, `NaN`, or an `undefined`-valued key now keeps that local form where the echo previously
  overwrote it with the serialized one. Such a caller was already desynchronised from the agent,
  which only ever sees the serialized form; both platform call sites pass plain JSON and are
  unaffected. Normalizing the optimistic write is tracked separately.

- [#457](https://github.com/skaile-ai/workspaces/pull/457) [`d7cfdac`](https://github.com/skaile-ai/workspaces/commit/d7cfdac0550fcb9f5919280617b729a7e8ba8317) Thanks [@peteralbert](https://github.com/peteralbert)! - Stop `AgentStore` from notifying subscribers on structural no-ops. `setStatus` wrote and
  notified unconditionally, and `capability_register` rebuilt the capabilities Map on every
  replay — so a byte-identical re-registration produced a fresh Map identity and a fresh
  snapshot. Because `_notify()` invalidates the cached snapshot, each no-op handed every
  `useSyncExternalStore` / Vue `shallowRef` consumer a brand-new snapshot identity for no
  reason. Under a stale-token SSE reconnect storm the gateway replays the whole capability set
  on every reattach, and combined with a per-frame status write that churn compounded into
  React error [#185](https://github.com/skaile-ai/workspaces/issues/185) ("Maximum update depth exceeded") in the platform frontend, crashing
  sessions.

  Three paths now short-circuit: a `setStatus` call carrying the status the store already
  holds (which also covers the `onStatusChange` transport callback, now routed through the
  guarded setter), a `capability_register` whose every capability is already registered with a
  structurally equal value, and a `capability_deregister` naming only capabilities that are
  absent. Capability comparison is serialized (`JSON.stringify`) equality with a fail-open
  `try/catch` — deliberately conservative, so a reordered-but-equal pair still notifies
  (harmless) but a genuine capability update can never be mistaken for a no-op.

  Consumers see fewer redundant re-renders and no behavioural change for real state
  transitions. The one semantics change: duplicate `setStatus` writes no longer notify, so a
  consumer that relied on a redundant status write as a de-facto "a reconnect happened" signal
  must now emit its own explicit signal.

- [#466](https://github.com/skaile-ai/workspaces/pull/466) [`63bf337`](https://github.com/skaile-ai/workspaces/commit/63bf33720d1effc9d98dd43cd83efe62ff5c648f) Thanks [@peteralbert](https://github.com/peteralbert)! - Stop `AgentStore` from notifying subscribers on a redundant `status` event. `case "status"`
  wrote `phase` and fell through to `_notify()` with no equality check, and because `_notify()`
  invalidates the cached snapshot, a status event carrying the phase the store already held
  handed every `useSyncExternalStore` / Vue `shallowRef` consumer a brand-new snapshot identity
  for nothing. This was the highest-volume instance of the churn fixed for `setStatus` and the
  capability registry: the bridge normalizer re-emits `phase: "working"` from three sites, two of
  which fire on every tool call, so in a turn with N tool calls N-1 of those events were full
  consumer fanouts for an unchanged phase.

  A `status` event whose `phase` equals the phase the store already holds now short-circuits
  before `_notify()`. `StatusEvent` carries nothing but `phase`, so the comparison is a primitive
  `===` — complete, not conservative: when the phase is unchanged, every field of the snapshot is
  unchanged. Flush semantics are untouched — `status` did not flush streaming text before and
  still does not; `finished` remains the canonical end-of-turn flush.

  Consumers see fewer redundant re-renders and no behavioural change for real phase transitions.
  The one semantics change: a repeated identical phase no longer notifies, so a consumer that
  treated a redundant status emit as a de-facto "the agent is still alive" heartbeat must now
  emit its own explicit signal. Status events remain individually observable upstream —
  `sdk/agent-session.ts` turns them into its own output lines, on its own dedupe rules — which is
  why the guard is store-local rather than applied at the emit site.

- [#481](https://github.com/skaile-ai/workspaces/pull/481) [`1657e15`](https://github.com/skaile-ai/workspaces/commit/1657e15b591b1078af73d699e07a4a1680bcb79d) Thanks [@peteralbert](https://github.com/peteralbert)! - Prevent structurally unchanged `AgentStore.updateState()` writes from replacing the shared-state
  Map, rotating framework snapshots, or notifying subscribers. The `state_update` transport command
  is still sent for every call, and unsupported, aliased, proxied, or caller-shared values retain the
  prior optimistic update-and-notify behavior.

- [#464](https://github.com/skaile-ai/workspaces/pull/464) [`00e22e6`](https://github.com/skaile-ai/workspaces/commit/00e22e6cca16210a75046e0dba8cfc9065078f4c) Thanks [@peteralbert](https://github.com/peteralbert)! - fix(runner): hot-plugged MCP servers reach the running claude-sdk driver, and survive

  `runner.attach_instance` / `runner.add_mcp_server` connected the server and registered its
  tools, but the `claude-sdk` driver composes its SDK `mcpServers` option once inside
  `startQuery` and keeps that query alive across turns — so the agent had no `mcp__<id>__*`
  tools until a full session restart. The runner now queues a driver rebuild (resuming the
  conversation) when the driver bakes its tool set, so the tools are callable on the **next
  turn**, not immediately mid-turn. The rebuild runs in the background: a synchronous one
  would wait out the in-flight turn and blow the platform's 30s runner-capability timeout.
  Concurrent attaches are serialized so two rebuilds can never overlap.

  Serve now also keeps a per-session registry of live-attached MCP declarations and replays it
  into every session build. The platform never folds a live attach into `skaile.yaml`, and a
  rebuild reads `skaile.yaml` only — so before this, any later recreate (a skill attach, a
  reconnect, a skill restage) silently dropped the hot-plugged server and its tools for the
  rest of the session.

  Because the declarations now take part in the session build, the system prompt's new
  `## MCP servers` section lists hot-plugged servers alongside declared ones, with their tool
  counts, next to the existing per-server MCP.md guidance.

  `live: true` from `runner.attach_instance` / `runner.add_mcp_server` therefore now means
  "live from the next turn", and `claude-sdk` sessions return it where they previously
  returned `false`. See MIGRATION.md.

- [#480](https://github.com/skaile-ai/workspaces/pull/480) [`9a55d68`](https://github.com/skaile-ai/workspaces/commit/9a55d68e4889a7ebdf775499413fe11de8f50ed5) Thanks [@peteralbert](https://github.com/peteralbert)! - Treat session `status` events as live-only state: active subscribers still receive them, but the dispatcher no longer persists them or assigns them sequence numbers.

## 1.16.0

### Minor Changes

- [#452](https://github.com/skaile-ai/workspaces/pull/452) [`24a95f7`](https://github.com/skaile-ai/workspaces/commit/24a95f7f235782e644be3754085bf63f916d52c6) Thanks [@peteralbert](https://github.com/peteralbert)! - Expose flow globals at runtime: render a "Flow globals" section in the orchestrator prompt and add a read-only get_globals flow-connector operation, so agents can read run_input on params-bound runs (fixes [#451](https://github.com/skaile-ai/workspaces/issues/451), upstream of skaile-ai/platform#2039).

## 1.15.1

### Patch Changes

- [#449](https://github.com/skaile-ai/workspaces/pull/449) [`1f50da7`](https://github.com/skaile-ai/workspaces/commit/1f50da7ada57f19801162b86f23161dafa54f275) Thanks [@peteralbert](https://github.com/peteralbert)! - Serialize concurrent Claude SDK prompts so each logical turn completes before the next begins.

## 1.15.0

### Minor Changes

- [#447](https://github.com/skaile-ai/workspaces/pull/447) [`8498b81`](https://github.com/skaile-ai/workspaces/commit/8498b81c267ae640d752446066b39fee915b4f3f) Thanks [@peteralbert](https://github.com/peteralbert)! - New `channels` module — chat-app integration core (rollout step 1 of the
  2026-08-07 channels design): `ChannelEngine` with host ports (SessionPort,
  IdentityPort, BindingStore, CredentialPort, IngressHost), the ChannelAdapter
  interface, a streaming egress pipeline (throttled serialized edit queues,
  frozen-boundary fence-safe chunking, markdown auto-close — vendored from
  CopilotKit under MIT with attribution), mention normalization onto the
  existing Mention union, markdown→Adaptive-Card formatting, and the MS Teams
  adapter (Bot Framework ingress + JWT validation, raw-fetch provider client,
  ackDeadlineMs 15000). New subpaths: `@skaile/workspaces/channels`,
  `@skaile/workspaces/channels/msteams`.

- [#446](https://github.com/skaile-ai/workspaces/pull/446) [`5771268`](https://github.com/skaile-ai/workspaces/commit/5771268483403133f0a74de04c2d69d803955495) Thanks [@peteralbert](https://github.com/peteralbert)! - Protocol 3.7.0 — acknowledge `connector_mutate`, and stop dropping flow mutates that lose the wake race.

  A `connector_mutate { id: "flow", … }` arriving before the flow connector was registered resolved to a bare dispatch, threw `Connector not found: "flow"`, and the throw was swallowed into a `serverLog.warn`. The host could not detect it at all, because the protocol had no `connector_mutate` result message. Seen in production as the ~2 ms race between an inbox approval and the bootstrap `flow.hydrate` on wake, and as a `cancel` against a session whose flow connector detached after a deploy.

  Three additive changes:

  - **Opt-in acknowledgement.** `ConnectorMutateCommand` gains `requestId?: string`. When set, the runner emits exactly one new `connector_mutate_response` event — `{ requestId, ok, result?, error?, code?, reconciled? }`, with `code` one of `connector_unavailable` / `rejected` / `op_failed` / `session_unavailable` — on every terminal path, including the previously swallowed one. When absent, nothing is emitted, so a 3.6 host sees byte-identical behaviour. The existing non-fatal `error` event on the reject path is unchanged.
  - **Flow-connector readiness wait.** A flow-targeted op that resolves to a dispatch against an unregistered connector now waits for the connector to appear, re-plans, and dispatches, instead of failing. Bounded by `SKAILE_FLOW_MUTATE_CONNECTOR_WAIT_MS`, default 8000 ms; on expiry the ack carries `connector_unavailable`.
  - **Hydrate-time reconciliation.** `flow.hydrate` accepts `payload.pendingDecisions: PendingGateDecision[]`, replayed in the same handler invocation that creates the connector — so there is no second command to race — and reported back as `reconciled: { applied, skipped }`. A decision that no longer applies is a structured skip (`not_parked` / `already_decided` / `unknown_node` / `payload_invalid`), never a throw; a replayed input response is validated against the node's own `inputSchema` and never logged or echoed.

  `connector_mutate_response` is registered as a transient dispatcher type, so acks are never persisted as chat messages. `handleConnectorQuery` is untouched.

## 1.14.1

### Patch Changes

- [#438](https://github.com/skaile-ai/workspaces/pull/438) [`8a51750`](https://github.com/skaile-ai/workspaces/commit/8a51750facff4172852ba6ae49aa1158b0563e31) Thanks [@peteralbert](https://github.com/peteralbert)! - Fix a session deadlock when a free-text prompt arrives while `AskUserQuestion` is pending:
  the Claude SDK driver now denies the question as superseded and waits for the paused turn to
  unwind before starting the new one, instead of queueing the message behind a turn that can
  never finish. `BridgeRuntime.reply()` now routes an answer into the paused turn (mirroring
  the runner) rather than forwarding it as a prompt.

## 1.14.0

### Minor Changes

- [#437](https://github.com/skaile-ai/workspaces/pull/437) [`d66e76f`](https://github.com/skaile-ai/workspaces/commit/d66e76ffd7fefea10fed7a83f6d9d3eca7a1e38d) Thanks [@peteralbert](https://github.com/peteralbert)! - Bind `AskUserQuestion` answers to the sub-question they belong to. The `reply` command gains an
  optional `question` field, and it is threaded through every consumer API that can produce a reply
  — `AgentClient.reply(answer, question?)`, `AgentStore.reply(answer, question?)`, and the
  `useAgentStoreActions().reply` wrappers in both `@skaile/workspaces/store` and
  `@skaile/workspace-bridge-react` — down to `AgentDriver.answerQuestion(answer, question?)`. All of
  them carry the question text verbatim from the `question` event that asked it, which is the same
  key the Claude SDK expects back in `answers`. The claude-sdk driver now files a named answer under that question
  and resolves the paused turn once every sub-question has one.

  Previously the driver filled the next unanswered slot positionally, so a host that let the user
  answer a multi-question call out of order silently attached each answer to the wrong question.
  Omitting `question` keeps the positional fallback, so existing hosts are unaffected; an unknown
  question name also falls back rather than stranding the turn.

### Patch Changes

- [#434](https://github.com/skaile-ai/workspaces/pull/434) [`20aa27d`](https://github.com/skaile-ai/workspaces/commit/20aa27dc95aa3179843670792490f82015d22c86) Thanks [@peteralbert](https://github.com/peteralbert)! - fix(runner): never forward unresolved secret refs to external MCP transports — a
  header/env value that matches a known secret-ref shape (`env:`, `mcp:`, …) but
  fails to resolve now aborts that server's spawn with an error naming the server
  id, key, and ref, instead of sending the literal ref (e.g. `mcp:github`) as the
  Authorization header and surfacing a misleading upstream rejection.

## 1.13.1

### Patch Changes

- [#431](https://github.com/skaile-ai/workspaces/pull/431) [`a9951a9`](https://github.com/skaile-ai/workspaces/commit/a9951a9ea6f108388e15a40d51a837685812ae18) Thanks [@peteralbert](https://github.com/peteralbert)! - fix(bridge): recover from a Claude SDK turn that never emits a result — a
  per-turn inactivity watchdog (`SKAILE_TURN_STALL_MS`, default 30 min) tears the
  wedged query down and rejects the turn, and `abort()` now forces the teardown
  when the consumer loop refuses to unwind.

## 1.13.0

### Minor Changes

- [#427](https://github.com/skaile-ai/workspaces/pull/427) [`7b0c197`](https://github.com/skaile-ai/workspaces/commit/7b0c19790b8f1004bf390207868b68445db854bd) Thanks [@peteralbert](https://github.com/peteralbert)! - fix(connectors): assert the fleet bind is a real mountpoint

  `ensureFleetMounted` accepted any readable directory as proof the host fleet bind was live, so a target-path mismatch between the driver and the host went undetected: the driver's own target dir exists as an empty local directory, verification passed, and the session silently ran against container disk instead of the remote.

  It now checks the resolved directory against `/proc/self/mountinfo` and fails the connect when it is a plain directory. Where the mount table is unreadable (non-Linux dev hosts) the previous existence-only check still applies. The check reads the kernel mount table rather than comparing device ids against the parent, because git-fleet worktrees are same-device bind mounts.

  Minor rather than patch: a deployment whose fleet bind never landed used to start (wrongly) and now aborts at connect time with a diagnostic error.

## 1.12.2

### Patch Changes

- [#422](https://github.com/skaile-ai/workspaces/pull/422) [`47a9896`](https://github.com/skaile-ai/workspaces/commit/47a989633a22c0c090fb7c50c4c3355738f73aca) Thanks [@Frozen666](https://github.com/Frozen666)! - Fix a dropped in-flight prompt when a skill-change driver restart races a live
  turn. The runner now serializes prompt turns against out-of-band driver swaps via
  a bidirectional gate: a swap defers to an in-flight turn (so the driver is never
  killed mid-prompt), and a prompt arriving during a swap binds to the fresh driver.
  Previously the killed SDK process emitted no completion event and the UI hung on
  "AI is thinking…" forever.

## 1.12.1

### Patch Changes

- [#419](https://github.com/skaile-ai/workspaces/pull/419) [`da98cc1`](https://github.com/skaile-ai/workspaces/commit/da98cc1b86636267e1f0855134fbeb229869ae2a) Thanks [@henkbla](https://github.com/henkbla)! - serve: stop treating a finished flow run as an active one. `activeFlows` was never pruned, so a run that reached `complete` / `cancelled` permanently blocked `flow.start` in a warm session — with a reject message advertising a remedy ("cancel it first") that could not work, since cancel leaves the entry in place — and kept re-emitting its settled snapshot on every reconnect. The `start` guard now rejects only on a non-terminal run, and a finished run is dropped when the next one registers. `failed` and `paused` stay non-terminal (`retryNode` revives them). A settled run is still replayed on connect until it is superseded: the turn loop keeps driving with no host attached, so that replay is the only path by which a host that was detached when the run finished ever learns it did.

## 1.12.0

### Minor Changes

- [#416](https://github.com/skaile-ai/workspaces/pull/416) [`d5d1cfe`](https://github.com/skaile-ai/workspaces/commit/d5d1cfe8b19e2e9c9e83d51db3919411b41a2419) Thanks [@Frozen666](https://github.com/Frozen666)! - session_init `resolvedConfig` gains an optional `agentConfig` (driver, provider, model, cloud, cloudConfig, thinking, effort) and the runner applies it as a disk-first fallback — broker-mode sessions without an on-disk skaile.yaml now honor the platform-resolved driver/model instead of booting compiled-in defaults. Protocol 3.6.0 (additive).

## 1.11.0

### Minor Changes

- [#412](https://github.com/skaile-ai/workspaces/pull/412) [`3b4bde5`](https://github.com/skaile-ai/workspaces/commit/3b4bde5451af0a413ba22afccb7a8e487bae67cd) Thanks [@peteralbert](https://github.com/peteralbert)! - Add mandatory hard approval gates that autonomous mode cannot skip.

  An approval-gate node may now set `data.approval.mandatory: true` in its flow
  definition. In autonomous mode the runner still parks a mandatory gate at
  `awaiting_approval` and requires a human `applyApproval(approved)` before it can
  `complete` — the `FlowAdapter` rejects a `complete_node` from `running` on a
  mandatory gate, and the orchestrator prompt tells the agent it may not skip it.
  Non-mandatory gates keep the existing auto-skip behavior; the flag defaults to
  absent/false, preserving current behavior exactly.

  The flag rides the flow definition JSON, so this is additive and not
  wire-visible: no protocol/envelope version bump, no `FlowExecution` shape
  change. `validateFlow` accepts and type-checks the flag. The scripted echo
  driver (`flow-script.ts`) resolves `onRun: "auto"` to `request_approval` on a
  mandatory gate even when autonomous, so hosts can e2e a hard gate
  deterministically.

## 1.10.3

### Patch Changes

- [#408](https://github.com/skaile-ai/workspaces/pull/408) [`b0e386f`](https://github.com/skaile-ai/workspaces/commit/b0e386f206f7a53b2d25b6cfb6ff1b4e3f909b19) Thanks [@peteralbert](https://github.com/peteralbert)! - Fix a shared-reference snapshot corruption in `FlowAdapter.mutate()` that could
  drop `state_changed` signals. After a signaling mutation, `lastSignaledExecution`
  aliases the live `execution` object; the next mutation's in-place
  `nodes[x] = {...}` reassignment mutated that "previous" snapshot before
  `computeStimulus` diffed it, so any transition whose only change is a
  non-fast-path field (e.g. an approved-only `approval.decision` flip) compared
  equal and produced no signal — an intermittent missed turn kick.

  `mutate()` now shallow-clones the top-level snapshot and its `nodes` map before
  running the mutator, so the mutator can no longer corrupt the previously-signaled
  snapshot. No behaviour change for mutations that already signalled. No
  protocol/envelope/flow-contract change.

- [#409](https://github.com/skaile-ai/workspaces/pull/409) [`70f0efd`](https://github.com/skaile-ai/workspaces/commit/70f0efdbf65c2803a72fdf084ec45fe8fcd0b4f1) Thanks [@peteralbert](https://github.com/peteralbert)! - Replay live store snapshots to a newly-attached subscriber, fixing cold-spawn
  warmup event loss. Host-op `state_changed` events (flow mutations, shared-state
  changes) fired in the first ~15-20s after a cold `skaile serve` spawn were
  dropped: `sendEvent` is fire-and-forward and the sole `transport.onConnect`
  handler emitted only availability, never state, so anything emitted before the
  host's sync loop attached was lost forever.

  A second `onConnect` handler now replays the CURRENT snapshot of every live
  state-bearing store (flow stores read the authoritative FlowExecution off the
  adapter; shared-state stores identified by the xstate-store `onStoreChange` hook,
  read via `get`; git/mount/MCP skipped). Snapshots are idempotent latest-wins on
  the host, so a late subscriber converges to the same state an early one holds and
  a reconnect re-applies identical state without divergence. Runs after
  availability so the host learns the stores exist before their state arrives, and
  is consistent with the session_init re-emit paths. No protocol/envelope/
  flow-contract change.

## 1.10.2

### Patch Changes

- [#406](https://github.com/skaile-ai/workspaces/pull/406) [`975dc26`](https://github.com/skaile-ai/workspaces/commit/975dc263aa581e9189e540afcb4afa6b15bbed48) Thanks [@peteralbert](https://github.com/peteralbert)! - Fix the HITL approve path: `applyApproval` / `applyInput` / `retryNode` now kick
  a resume turn with the correct typed stimulus (workspaces#404). Previously an
  approved gate could hang forever — the approved branch records the decision but
  leaves `status = awaiting_approval` and relied on the generic
  `mutate() -> computeStimulus() -> bus.signal()` path, which flattens everything
  to `state_changed` and (for approvals, which leave `status`/`focus`/`done`
  unchanged) can drop the signal entirely, so no turn was kicked.

  The runner's `connector_mutate` dispatch now emits an explicit typed stimulus
  (`approval_received` / `input_received` / `retry_requested`) after applying the
  op, fire-and-forget — mirroring the op:start `flow_started` kick ([#399](https://github.com/skaile-ai/workspaces/issues/399)). This
  also gives the agent the correct resume guidance instead of a bare
  `state_changed`. `driveTurn` reconstructs those kinds and, when signals coalesce,
  prefers the most-specific stimulus in the batch so a trailing `state_changed`
  can't mask the resume stimulus. Reject/input/retry paths are unchanged. No
  protocol/envelope/flow-contract change. (The underlying shared-reference quirk in
  `FlowAdapter.mutate()` is tracked separately in [#405](https://github.com/skaile-ai/workspaces/issues/405); this fix is correct
  independent of it.)

## 1.10.1

### Patch Changes

- [#402](https://github.com/skaile-ai/workspaces/pull/402) [`f717cf3`](https://github.com/skaile-ai/workspaces/commit/f717cf39cdbf02f2878723f18f4592bbee4f8602) Thanks [@peteralbert](https://github.com/peteralbert)! - Make `[test-flow]` scripted-driver arming robust to host message decoration
  (workspaces#401). Hosts decorate user messages (e.g. the platform prepends a
  `<SESSION_MEMBERS>` block and a wake-restoration preamble), so the previous
  message-anchored match (`/^\s*\[test-flow\]/`) never fired and the scripted
  driver silently failed to arm. Detection is now line-anchored: the echo driver
  arms when a line **begins** with the exact `[test-flow]` token (leading
  whitespace allowed) anywhere in the composed prompt, with the optional JSON
  script on that same line. This is decoration-independent yet still impossible to
  arm from ordinary prose (a line must start with the token). No contract change.

## 1.10.0

### Minor Changes

- [#400](https://github.com/skaile-ai/workspaces/pull/400) [`cdda9fc`](https://github.com/skaile-ai/workspaces/commit/cdda9fc9501d6e1381943c45a17a2031ddc401ea) Thanks [@peteralbert](https://github.com/peteralbert)! - Fix claude-sdk flow integration so real-agent flow runs work end-to-end
  (workspaces#399). Three gaps closed:

  1. **`op:start` now kicks the first turn.** The `create-start` connector_mutate
     branch called `createActiveFlow` and returned without waking the agent —
     `connect()` seeds state but never mutates, so nothing signaled the stimulus
     bus. It now signals a `flow_started` stimulus, so `driveTurn` reassembles the
     `# Flow Execution Context` orchestrator prompt and drives turn 1 for every
     driver (fire-and-forget, so the mutate ack doesn't block on a long/gated
     turn). This is the contract's "emit initial state, kick first turn".

  2. **Flow-adapter agent tools are now callable by the claude-sdk agent.** New
     `buildSdkFlowTools` exposes the flow connector's agent operations
     (`start_node`, `complete_node`, `request_approval`, `request_input`,
     `skip_node`, `fail_node`, plus the read ops) as first-class MCP tools
     (`mcp__skaile-flow__<op>`), injected in `createActiveFlow`. Previously the
     generic connector server emitted no per-op tools and omitted `connector_exec`
     under shell access, so the agent could not discover or invoke the tools the
     orchestrator prompt told it to call. Host-only ops (`applyApproval`,
     `cancel`, `start`, …) are intentionally excluded.

  3. **Orchestrator prompt is injected on flow turns.** Follows from (1): the
     start turn and every host/user-message-driven turn now go through
     `buildOrchestratorPrompt` instead of a plain chat prompt.

  No protocol/envelope/flow-contract change. Hosts consume this by bumping
  `@skaile/workspaces`; `op:start` now emitting an initial `state_changed` and
  kicking a turn is additive (idempotent latest-wins persistence) and does not
  affect the eager-persist or start-then-hydrate paths.

- [#398](https://github.com/skaile-ai/workspaces/pull/398) [`2b95465`](https://github.com/skaile-ai/workspaces/commit/2b9546558968d5d60f79096b0c3a9f80f856fd5e) Thanks [@peteralbert](https://github.com/peteralbert)! - Add a deterministic, no-LLM **scripted-flow mode** to the echo driver so host
  e2e suites can drive flows end-to-end (autonomous run to `complete`, approval
  gate resume, reject/recover) without spending model tokens or relying on model
  nondeterminism. A `[test-flow]` sentinel chat message arms the mode (optionally
  carrying a JSON per-node script); orchestrator turns are then driven by a
  reactive policy over live flow state via a runner-injected `flowTestDriver`
  hook. It emits only the existing FlowAdapter agent tools — no new protocol,
  envelope, or flow-contract change. See `docs/flow-execution.md` → "Testing: The
  Scripted Flow Driver".

  Also fixes the lost initial `op:start` snapshot: `createActiveFlow` now emits
  the initial `state_changed` right after registering the state listener, so
  hosts receive the starting flow state before any mutation (idempotent
  latest-wins, so a duplicate is harmless).

### Patch Changes

- [#394](https://github.com/skaile-ai/workspaces/pull/394) [`d917573`](https://github.com/skaile-ai/workspaces/commit/d917573aac96de247ee35762a3f42fd29f0fef38) Thanks [@peteralbert](https://github.com/peteralbert)! - Run-group contract doc clarifications (no behavior change): `maxBudget` /
  `costCurrency` are decimals in the host's reporting currency (Skaile
  platform v1 records USD), not hard-coded EUR; `rollup` TSDoc now states
  explicitly that `cancelled` groups never reactivate.

## 1.9.1

### Patch Changes

- [#391](https://github.com/skaile-ai/workspaces/pull/391) [`48e77a7`](https://github.com/skaile-ai/workspaces/commit/48e77a7a9b81b23a4b01766cd63340caf4660c6c) Thanks [@Frozen666](https://github.com/Frozen666)! - Let the Tier-2 git credential self-heal recover an empty or missing credentials file: the helper now treats emptiness as a refresh trigger, and `atomicReplaceCredential` upserts instead of throwing.

- [#388](https://github.com/skaile-ai/workspaces/pull/388) [`e8ca12c`](https://github.com/skaile-ai/workspaces/commit/e8ca12c74a3ca0386880b665560f9d7ce4f7f7d5) Thanks [@Frozen666](https://github.com/Frozen666)! - fix(serve): wire the capability surface on every `session_init`, not just the create branch

  The remote capability invoker and the `runner.*` capabilities were registered only inside
  `buildSessionFromInit`'s create branch. An env-mode session (no `skaile.yaml` — the broker
  container shape) is built eagerly at boot, so it already has a session by the time
  `session_init` arrives and never entered that branch: `host.refresh_credential` threw for
  lack of an invoker (so `auth: backend` wire connectors could mint but never refresh),
  platform hot-plug dispatches (`runner.attach_instance` / `runner.add_mcp_server` /
  `runner.lifecycle`) failed, and the tier-1 resume `capabilitySignature` drifted from the
  docker shape.

  The wiring now lives in `session-capability-wiring.ts` and runs on every `session_init`
  regardless of which branch built the session. It is idempotent (`setRemoteInvoker` replaces,
  `register` is overwrite-by-name) and its handlers close over getters, so they follow a
  subsequent wire-fallback recreate rather than pinning the disposed session. No behaviour
  change on the docker/provisioned path.

- [#392](https://github.com/skaile-ai/workspaces/pull/392) [`a4b80cf`](https://github.com/skaile-ai/workspaces/commit/a4b80cf1ee453632eddd78ca64968d444b2d0936) Thanks [@Frozen666](https://github.com/Frozen666)! - Demote the serve.ts `[serve] debug query=` diagnostic from info to debug so the platform's ~10s config/mcp polls no longer flood the per-session logs.db.

## 1.9.0

### Minor Changes

- [#387](https://github.com/skaile-ai/workspaces/pull/387) [`5abd6d9`](https://github.com/skaile-ai/workspaces/commit/5abd6d9260997b5cc0b9c6fe5362ac317a2dcef8) Thanks [@peteralbert](https://github.com/peteralbert)! - Flow-connector host contract fix ([#379](https://github.com/skaile-ai/workspaces/issues/379)): hydrate-bootstrap, dead activeFlows, protocol 3.5

  `connector_mutate { id: "flow", op: "hydrate", payload: { state, flow } }` now
  auto-creates the `flow:<runId>` connector on a cold container when no flow is
  active, so a persisted run rehydrates in a single op. `flow` (FlowDefinition) is
  required in that bootstrap case and rejected loudly if missing; malformed or
  conflicting `start` / `hydrate` payloads now warn with a reason instead of
  silently falling through. The prior start-then-hydrate sequence still works.

  Removes the dead `SessionInit.activeFlows` field and its `ActiveFlowRehydration`
  type (the runner never read them) and adds `runner/MIGRATION-flow-connector.md`
  documenting the old-command -> op mapping and the canonical rehydration
  sequences.

  PROTOCOL_VERSION bumps to 3.5.0. The handshake compares only `major`
  (major-equality rule; minor/patch ride through transparently), so a 3.4 host and
  a 3.5 runner interoperate: the removed field was already inert on the wire and
  the hydrate-bootstrap path is opt-in on the payload the host sends. The bump is a
  feature-detection signal that lets hosts drop the start-then-hydrate workaround.

### Patch Changes

- [#384](https://github.com/skaile-ai/workspaces/pull/384) [`acca5e7`](https://github.com/skaile-ai/workspaces/commit/acca5e73957d4102ca547736560af4ce1be46483) Thanks [@henkbla](https://github.com/henkbla)! - fix(serve): repoint the resource manager after a session recreate, so a skill install no longer empties the workspace explorer. `restartDriverForSkillChanges` (skill hot-plug) and the flow-connector-tools restart both disposed and recreated the agent session without reassigning the outer `resourceManager` var. `dispose()` runs `disconnectAll()`, which clears the manager's `active` map, so every reader — the resource-capability getters, `readResource`, `emitAvailability` — kept reading a torn-down manager whose `listConnectors()` returned `[]` permanently, for the rest of the session's life. The explorer showed an empty workspace while the files sat untouched on disk, and no `emitAvailability()` re-emit meant the gateway's cached `resources_available` was never refreshed either. `agentSession.startWatching()` was also never called on the recreated session, so file-change events stopped after a skill install.

  All three recreate sites now route through one `recreateAgentSession` helper carrying the dispose → create → swap → repoint → `startWatching` → `emitAvailability` sequence, so the two paths that had drifted from `recreateSessionForInit` cannot drift again. The repoint itself is `pickRepointedManager` (pure, unit-tested): the recreated session's manager wins when it exists; a manager the disposed session owned is dropped to null rather than served torn-down; and a manager lazily created for sharedState — which the session never owned and `dispose()` therefore never disconnected — is preserved, since nulling it would silently drop every registered shared-state store.

  Known gap, logged loudly rather than hidden: when the recreated session declares connectors AND the outer var held a live lazily-created sharedState manager, the new manager wins and that manager's shared-state stores are dropped. The two cannot be merged here. Not a regression — the naive assignment dropped them too — and the runner now warns when it happens.

  Fixes skaile-ai/workspaces#383.

## 1.8.1

### Patch Changes

- [#381](https://github.com/skaile-ai/workspaces/pull/381) [`f1103e5`](https://github.com/skaile-ai/workspaces/commit/f1103e51491faf86fd5bc841c67f130b6db197cd) Thanks [@Frozen666](https://github.com/Frozen666)! - fix(serve): apply session_init wire connector declarations when the session was eagerly built in env mode. Broker containers have no skaile.yaml, so serve resolves env secrets mode and builds the agent session at boot; the session_init wire-declaration fallback (from the disk-first fallback work) then skipped the create branch and dropped the declarations — broker sessions registered zero resources. buildSessionFromInit now recreates the eagerly-built session when it has no resource manager and the wire supplies declarations. Disk-first invariant unchanged: a disk-built resource manager always wins and an empty wire is a no-op, so docker/local sessions behave byte-identically.

## 1.8.0

### Minor Changes

- [#378](https://github.com/skaile-ai/workspaces/pull/378) [`0c1104f`](https://github.com/skaile-ai/workspaces/commit/0c1104f46aab6e839f8ad14a4067f6818c906747) Thanks [@peteralbert](https://github.com/peteralbert)! - Add the run-group contract: shared types (`@skaile/workspaces/types`:
  RunGroup, RunInstance, InputEnvelope, RunGroupPolicy, ...) and the pure
  `@skaile/workspaces/run-group-engine` helpers (deriveInstanceStatus, admit,
  rollup, planRetry) for the fan-out / dark-automation layer. Contract doc:
  docs/run-group-execution.md. No runtime behavior changes.

- [#373](https://github.com/skaile-ai/workspaces/pull/373) [`2c2336b`](https://github.com/skaile-ai/workspaces/commit/2c2336bac1ffd45e9f6b314d818e98f9da3886e0) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Wire `skaile store search <query>` and `skaile store show <ref>` (previously stubs).

  - `store search` browses the remote catalog: `openCatalogSource` → `listAssets` (with a server-side `--kind` filter) → client-side `filterCatalogAssets` over name/description/publisher (same query semantics as `skaile search --store`), printing copyable `kind:@publisher/name#version` refs.
  - `store show` prints a store asset's details plus provenance: `resolve(ref)` for the card (kind, publisher, version, description, category, license, soft-deps) and `getInstallManifest(ref)` for the pointer triple (tier, source repo + commit, per-file SHA256, composite integrity). Provenance is shown only for the tRPC `RemoteCatalogSource` (guarded by `supportsInstallManifest`); a `catalog.url: local` config surfaces a clear disabled message.

- [#377](https://github.com/skaile-ai/workspaces/pull/377) [`ce486a0`](https://github.com/skaile-ai/workspaces/commit/ce486a030b7e8305a9cda0300b8ebc7fe6ff4ca3) Thanks [@peteralbert](https://github.com/peteralbert)! - `AgentStore`: treat a mid-turn human message as a streaming boundary so the assistant's in-flight text finalizes as its own message instead of running on.

  - `_handleEvent` now flushes streaming text on a `user_message` event (a peer's message broadcast while the agent streams), so observers see the current bubble close and the next `text` delta open a fresh one after the interjection.
  - `prompt()` flushes any in-flight streaming text before appending the prompt, so a sender who interjects mid-turn doesn't merge the agent's partial reply into their own message.

  Both are flush-only and idempotent (no-op at turn start). Fixes the run-on assistant bubble on interjection (skaile-ai/platform#1553).

## 1.7.0

### Minor Changes

- [#371](https://github.com/skaile-ai/workspaces/pull/371) [`d7b6224`](https://github.com/skaile-ai/workspaces/commit/d7b62247e956af89ab5deae61c8919f1c2eba7ee) Thanks [@Frozen666](https://github.com/Frozen666)! - feat(runner): register connectors from the `session_init` resolvedConfig when disk declares none. New `resolvedConfigToDeclarations()` export in `@skaile/workspaces/connectors` maps the Protocol v3 wire mounts/connectors (flat `source`/`target`/`watch`) to `ConnectorDeclaration[]`; serve mode passes the result as the new optional `AgentSessionConfig.fallbackConnectorDeclarations`. Disk-first invariant: the fallback engages only when `skaile.yaml` + materialized assets yield zero declarations (broker mode, where no yaml file is bound into the container) — any disk declaration keeps behavior byte-identical to before. CLI/flow/REPL callers are unaffected.

### Patch Changes

- [#364](https://github.com/skaile-ai/workspaces/pull/364) [`53380bc`](https://github.com/skaile-ai/workspaces/commit/53380bc9dc836a4ae6ed994addc1be76458e58c9) Thanks [@Frozen666](https://github.com/Frozen666)! - fix(cli): configure the SuperJSON transformer on the remote `session logs` tRPC client (query + tail). The platform tRPC server uses SuperJSON, so without a matching transformer every remote call failed with "Unable to transform response from server", making `skaile session logs --platform-url …` unusable against the deployed platform.

- [#372](https://github.com/skaile-ai/workspaces/pull/372) [`8cea0fd`](https://github.com/skaile-ai/workspaces/commit/8cea0fde55ed5b9a91e84d31afa02f85b60a61d4) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Fix four issues surfaced in a CLI test round:

  - `source manifest publish` now defaults the publisher namespace to the verified GitHub handle (`githubLogin ?? username`), matching the `library create` / publish-preflight precedence. A user whose store login differs from their GitHub handle no longer defaults to a reserved `@<login>` namespace (`--as` still overrides).
  - `install --locked` now restores bundled factory (`@skaile-ai/*`) assets. The bundled factory source shares the ai-assets identity URL but ships its bytes in the package; lock verification now resolves it to its bundled tree (and skips the fetch) instead of the ai-assets clone, whose tree never contained the bundled paths — which made `verifyLock` false-report every factory asset as `missing`.
  - `skaile manage` now exits with a clear "requires an interactive terminal" message when stdin is not a TTY, instead of an uncaught `setRawMode` TypeError.
  - The `preset init` template declares placeholders per-item (schema-valid) instead of at the top level, so authored placeholders resolve and `preset validate` counts them.

- [#357](https://github.com/skaile-ai/workspaces/pull/357) [`6b90565`](https://github.com/skaile-ai/workspaces/commit/6b905650ae8b8ea474dd8fff56f96695ee63c78c) Thanks [@P1umPudding](https://github.com/P1umPudding)! - `library publish --dry-run` now surfaces a namespace-availability status line
  when you are signed in to the store. Before committing a publish you can see
  whether the manifest's `publisher` namespace is yours, claimable-by-you,
  already reserved, or freely available — reusing the store's own read-only
  availability probe so the advisory can't drift from what a real publish
  enforces. The check is best-effort: it makes a single read-only GET and is
  silently skipped when offline or not signed in, so the dry-run stays fully
  offline-safe.

- [#359](https://github.com/skaile-ai/workspaces/pull/359) [`ee301ed`](https://github.com/skaile-ai/workspaces/commit/ee301ed8d18d2f00e69f0f3ac3b4acc6c536871a) Thanks [@P1umPudding](https://github.com/P1umPudding)! - `library create` now stamps the author's verified store identity (GitHub handle,
  else Keycloak username) as the asset publisher instead of the library name — the
  library name was almost never a namespace the author could publish, so publishes
  only failed at the store. `resolveLibraryPublisher` gains an injected store-identity
  fallback (precedence: `--publisher` → domain → store identity → library name), and
  a new `getStoreIdentity()` decodes it locally from the cached token (no network).

  `library publish` / `register` now preflight the target namespace against the
  store's availability probe **before** the public git push: a `reserved` or
  `claimable-by-you` namespace aborts fast with guided next steps (claim it / connect
  GitHub) instead of pushing bytes and then failing with a raw 422. The probe is
  advisory — an offline failure never blocks a valid publish.

- [#355](https://github.com/skaile-ai/workspaces/pull/355) [`5cade1f`](https://github.com/skaile-ai/workspaces/commit/5cade1f812ea08d50cfccfff06e41aede9d794f1) Thanks [@P1umPudding](https://github.com/P1umPudding)! - `library publish` / `register` / `source manifest publish` now send the manifest's
  `publisher` as the target `namespace` to the store's `sources.add`. When it
  matches your store login the store takes the personal path (unchanged); when it's
  a **claimed** namespace (e.g. `@P1umPudding` ≠ your login), the store resolves the
  claimed Publisher, so you can publish under a namespace that isn't your login
  username. Requires the namespace to be claimed in the storefront first.

- [#367](https://github.com/skaile-ai/workspaces/pull/367) [`9f86b3a`](https://github.com/skaile-ai/workspaces/commit/9f86b3a17146bbe9508eb79de132c9651bd23f77) Thanks [@P1umPudding](https://github.com/P1umPudding)! - `skaile store login` / `whoami` no longer conflate your login name with your
  publisher namespace. Login showed `Signed in as @plumpudding` and `whoami`
  labelled `@plumpudding` as your **Publisher** — but `@plumpudding` is your
  Keycloak login, not a namespace you publish under (your namespace is your GitHub
  handle, e.g. `@P1umPudding`). Now: login says `Signed in as plumpudding —
publishing as @P1umPudding`, and `whoami` shows `Login` and `Publisher` (the
  connected GitHub handle, or "connect GitHub to publish") as separate lines.

## 1.6.1

### Patch Changes

- [#354](https://github.com/skaile-ai/workspaces/pull/354) [`2e88f72`](https://github.com/skaile-ai/workspaces/commit/2e88f722b4b2df4888601ebfa3cf635264a004e7) Thanks [@henkbla](https://github.com/henkbla)! - fix(app-sdk): re-send the ready ping until the host answers

  `resolveAppConnectionParams` posted `skaile_app_ready` exactly once and then waited
  out its timeout. The host only mints a connection-param ticket in response to a ping
  and never speaks unprompted, so an app that booted before the host's own state had
  settled lost the race permanently: its single ping was dropped, nothing re-asked, and
  the app failed with `timed out after 10000ms waiting for connection params from the
host` against a healthy, minting host.

  The ping now repeats until answered, backing off 100 ms → 1 s within the same
  `timeoutMs` deadline (the host mints a single-use ticket per answered ping, so the
  backoff keeps an unlucky boot from churning tickets). Behaviour is unchanged when the
  host is already ready — still one ping, one ticket.

- [#347](https://github.com/skaile-ai/workspaces/pull/347) [`42f1285`](https://github.com/skaile-ai/workspaces/commit/42f1285b145aa055d2956c753461e4d71c2567c1) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Make locally-authored library assets consumable without publishing. `skaile add` /
  `skaile install` now fall back to the user's registered libraries when the
  source/store pass misses: each library is scanned on disk under its declared
  publisher and a `kind:@publisher/name` match is deployed through the normal copy
  pipeline and recorded as a dependency (never written to the lock — a local
  authoring place is not a reproducible source). Version pins are honoured with the
  same `matchPin` SemVer-range matcher the source resolver uses.

  `skaile asset install` / `asset show` additionally normalize the `kind:@pub/name`
  wire ref (with or without a `#version` range/exact/SHA pin) instead of requiring
  the fully-canonical stored id, and `asset install --from <library>` now scopes
  resolution to that library rather than only warning on a mismatch. Closes the
  `library create` → `add` loop end-to-end.

  `coerceVersion` and `matchPin` are now exported from `@skaile/workspaces/core`.

- [#350](https://github.com/skaile-ai/workspaces/pull/350) [`c786467`](https://github.com/skaile-ai/workspaces/commit/c7864673bcdb29e013649c8fb93ac0d4691064ee) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Fix two gaps that blocked the `library create → publish` round-trip:

  - `library publish --create-repo --dry-run` no longer actually creates a GitHub
    repo — the `gh repo create` step was running before the dry-run guard. A
    dry-run now just shows the would-be remote.
  - `library create` now registers the scaffolded asset in the manifest `assets[]`
    (the inventory `library publish` filters by), so a freshly-created asset is
    actually publishable instead of yielding "0 assets". `library index` reconciles
    the inventory with disk, so a hand-added asset (created outside `library
create`) becomes publishable too — without dropping curated entries.

- [#347](https://github.com/skaile-ai/workspaces/pull/347) [`42f1285`](https://github.com/skaile-ai/workspaces/commit/42f1285b145aa055d2956c753461e4d71c2567c1) Thanks [@P1umPudding](https://github.com/P1umPudding)! - `skaile manage` TUI: make library assets first-class in the browser.

  - **Libraries tab** is now an expandable, installable drill-in tree (`Libraries → library → domain → asset`) mirroring the Sources tab — expand/collapse with `←/→`, bulk-select a whole library from its header, install/remove per asset — instead of a read-only management list.
  - **Assets tab**: library assets group under a clean `<library-name>` band with no `library:` prefix and no redundant same-named domain level (previously `libraries > library:my-lib > library:my-lib > greet`).
  - **Assets tab**: every origin band except the built-in `factory` now default-collapses (one-shot, a user expand survives reloads), so a project with many sources/libraries opens compact.

- [#349](https://github.com/skaile-ai/workspaces/pull/349) [`64eeaa4`](https://github.com/skaile-ai/workspaces/commit/64eeaa4a3101e31934ec19d554a4c7dd44eef261) Thanks [@P1umPudding](https://github.com/P1umPudding)! - `skaile store login`: implement PKCE (RFC 7636, S256) in the Keycloak device
  flow. The `skaile-store-cli` client enforces PKCE, so the device-authorization
  request now sends a `code_challenge` + `code_challenge_method=S256` and the token
  poll sends the matching `code_verifier`. Without it Keycloak rejected the request
  with `invalid_request: Missing parameter: code_challenge_method`.

- [#352](https://github.com/skaile-ai/workspaces/pull/352) [`b8c30ce`](https://github.com/skaile-ai/workspaces/commit/b8c30ce9ad597244264c30a61a07819a1325503b) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Surface the store's human-readable error on `library publish` / `register` /
  `yank`. The store returns `{ error, message }` on failures; the CLI threw the
  bare `error` code (`UNPROCESSABLE_CONTENT`) and — because the publish commands
  didn't catch it — crashed with a raw stack trace. Now `storeFetch` prefers the
  actionable `message` (e.g. "Namespace '@x' is reserved for the GitHub identity …
  Sign in with GitHub to claim it") and the commands print it cleanly via `logErr`.

## 1.6.0

### Minor Changes

- [#346](https://github.com/skaile-ai/workspaces/pull/346) [`b878750`](https://github.com/skaile-ai/workspaces/commit/b878750cb8a99c317a074e88acad3f9e102b6df2) Thanks [@henkbla](https://github.com/henkbla)! - `runner`: carry an embedded app's `origin: {kind:'app', appId}` end-to-end instead of
  relabelling it to `client`.

  The platform gateway used to forward an app's capabilities to the runner relabelled as
  `origin:'client'`, because the origin-trust guard rejected an `app` origin from any
  source but a (non-existent) direct app connection. That relabel worked, but it erased
  the app's identity before anything downstream could use it: the per-app audit slice the
  design calls for was impossible, `ORIGIN_PROMPT_ORDER`'s `app` row and
  `isUserInvokable`'s `app` row were unreachable, and the agent could not tell which app
  owned which action.

  **Trust-guard change.** `source: 'client'` may now declare `origin.kind: 'app'` as well
  as `'client'`. The platform gateway is the only client peer; it binds every app
  registration to the socket it arrived on before forwarding, and the runner cannot verify
  an `appId` in any case. The guard that actually enforces something is unchanged and
  still live: `source: 'agent'` may never claim an `app` origin, so a skill, connector, or
  flow cannot impersonate an app.

  **Audit.** A capability handler's logger is now built with an `appId`-qualified instance
  for app origins: `capability:app:<appId>.<name>`. Exported as `capabilityLogInstance()`.

  `side: 'app'` needs no separate dispatch path — `buildClientCapabilityHandler` already
  round-trips the invocation to the platform, which routes it to the app connection.

  **Compatibility.** A platform that forwards `app`-origin capabilities requires a runner
  built from this version or later; an older runner drops them with an origin-mismatch
  warning. The runner ships in the agent container image, so the image and the platform's
  `@skaile/workspaces` pin must move together. No wire-type change — `origin.kind:'app'`
  and `side:'app'` have been in the protocol since 3.4.0; this only widens who may declare
  them.

### Patch Changes

- [#342](https://github.com/skaile-ai/workspaces/pull/342) [`9b362db`](https://github.com/skaile-ai/workspaces/commit/9b362dbd33f58658caa1eed56278b4e2198783de) Thanks [@henkbla](https://github.com/henkbla)! - `runner`: `CapabilityRegistry.hydrate()` no longer restores embedded-app capabilities.

  App capabilities are the app SDK's responsibility to re-register — it reconnects and
  replays them on session resume. Hydrating them would leave the agent handler-less stubs
  for an app whose back-connection is gone after a wake: tools it can see and never call.

  The existing `origin.kind !== 'client'` skip could not catch them. The platform gateway
  forwards an app's capabilities to the runner relabelled `origin:'client'`, so they look
  like ordinary client capabilities by the time they reach the registry. `hydrate()` now
  also skips the reserved `app.` namespace, which the gateway enforces on registration and
  which therefore survives the relabel.

  Latent fix: `serialize()` / `hydrate()` have no callers today (the runner does not yet
  write a capability snapshot), so nothing changes at runtime. It closes the trap before
  capability snapshotting lands.

## 1.5.0

### Minor Changes

- [#340](https://github.com/skaile-ai/workspaces/pull/340) [`cde9457`](https://github.com/skaile-ai/workspaces/commit/cde9457c22d7e58111156effec1d4f5a8b9c50ec) Thanks [@henkbla](https://github.com/henkbla)! - `app-sdk`: connection-param handshake, reconnect, and registration replay.

  The embedded app needs three things to open its back-connection — the gateway
  URL, its `appId`, and a session identity token. The token is a credential, and
  the preview surface that serves the app is unauthenticated, so it must not ride
  in the iframe `src`. `resolveAppConnectionParams()` obtains it over
  `postMessage` instead: the app posts a `skaile_app_ready` ping to its embedder
  and awaits a `skaile_app_params` reply, with both sides pinning the peer on
  `event.origin` **and** `event.source`. The app learns the expected host origin
  from a non-secret `skaileParentOrigin` query param — safe to leak precisely
  because it is not a credential. `"*"` and `"null"` host origins are refused: an
  unpinnable peer defeats the point of keeping the token out of the URL.

  `createWebSocketAppTransport` now accepts a `SessionTokenProvider` for
  `sessionToken` as well as a bare string. The gateway's token is **single-use**
  (consumed at `app_hello`), so a reconnecting transport cannot replay the one it
  opened with; a provider mints a fresh token per attempt. A bare string still
  opens the socket synchronously.

  `createSkaileApp` gains a `reconnect` policy (default
  `{maxAttempts:5, baseMs:1000, maxMs:30000}`, or `false` to disable). It rebinds
  after a dropped socket and **replays its capability registry**, since the
  gateway deregisters an app's capabilities when its connection closes. It also
  retries a _retryable_ `app_hello_nack` — the app boots independently of the
  session's agent container and can win that race. A permanent refusal (bad token,
  `appId` not permitted) is never retried, because retrying only burns tokens.
  `AppHelloNackMessage` gains an optional `retryable` field to carry that
  distinction, and the SDK surfaces it as `AppHandshakeRefusedError.retryable`.
  New `onSessionChange(handler)` fires on every bind — including the initial
  `connect()` — and with `null` whenever the connection drops.

  Behaviour change for `1.4.0` consumers: reconnect is **on by default**, so a
  dropped socket now re-dials instead of leaving the app inert. Pass
  `reconnect: false` for the old single-shot behaviour. Purely additive otherwise
  — new exports, a widened `sessionToken` type, and one new optional wire field.

- [#344](https://github.com/skaile-ai/workspaces/pull/344) [`fdd6c6a`](https://github.com/skaile-ai/workspaces/commit/fdd6c6a2bf89b677274665edd31ba9c6f71e5408) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Wire `skaile library publish` / `register` / `yank` — first-party store publish

  Publish a git-backed library you own (in-repo `skaile.manifest.yaml`) to the
  store: `library publish <lib>` commits + pushes the library repo, registers +
  syncs it as a store source (no sidecar — the in-repo manifest drives discovery),
  and publishes its assets under the manifest's own `publisher` (no namespace
  remap). `--asset <ref>` narrows to one asset; `--create-repo` provisions + links
  a public `github.com/<you>/<lib>` via `gh` when the library has no remote;
  `--dry-run` prints an offline plan. `register` skips the commit/push (already on
  GitHub); `yank` unpublishes. Requires a public git remote + `store login`.

  Extracts the shared store-interaction core (`publish-core.ts` `publishToStore`)
  reused by both `library publish` and `source manifest publish`, so the two
  front-ends can't drift on the register → sync → candidates → per-asset publish
  sequence.

- [#335](https://github.com/skaile-ai/workspaces/pull/335) [`84565e0`](https://github.com/skaile-ai/workspaces/commit/84565e09c4da6daab4f26d9402cefd42884588ce) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Sidecar authoring + consume bug fixes for third-party sources.

  **Sidecar authoring (`skaile source manifest init` / `edit` + overlay read + add).** You can
  now curate assets from a foreign GitHub repo you don't control by authoring a
  `skaile.manifest.yaml` stored _outside_ that repo, then consume + `add` them under a clean
  curated publisher.

  - `skaile source manifest init <src> [--publisher <name>] [--dev] [--force]` — clone (if
    needed), run discovery, and write a curated manifest to the local store catalog at
    `~/.skaile/store/manifests/<slug>.yaml`. Publisher defaults to the source's GitHub org.
    Every asset inherits the top-level publisher (no per-asset path-segment stamp).
    Idempotent (won't overwrite without `--force`).
  - `skaile source manifest edit <src>` — open the curated manifest in `$EDITOR` and
    re-validate on save (warnings surfaced, never a hard fail).
  - `skaile source add` / `source sync` apply the curated manifest as a **read overlay**:
    the curated publisher + asset inventory win over raw glob discovery. `LocalCatalogSource`
    gained a `sidecarManifestFile` option; discovery gained `loadMergedSourceConfigFromFile`
    - a `sidecarManifestFile` discovery option; the library gained `writeCuratedManifest`.
  - `skaile add` now resolves the curated publisher too: the provenance walker
    (`buildProvenanceIndex`) reads the same curated store overlay (via a new
    `SourceClone.overlayManifestPath`), so the ref `search --local` displays
    (`skill:@lark/…`) is the ref `add` accepts and deploys.
  - Store manifests are keyed by a collision-free `storeManifestSlug(url)`
    (`<host>__<owner>__<repo>`), so two repos sharing a trailing name (`orgA/cli`,
    `orgB/cli`) no longer collide on `cli.yaml`.

  **Bug fix — publisher normalization (glob AND manifest paths).** Discovery stored
  publishers _with_ a leading `@`, which the local ref formatter doubled into `@@scripts`
  (glob) / made `@lark` assets un-addable (manifest/curated overlay). `DiscoveredAsset.publisher`
  is now **bare** on every discovery path (glob + manifest, local-FS + virtual-tree), unifying
  them; the single `@` scope sigil is re-added by the ref formatter, and refs carry exactly
  one `@` across both modes. The internal `SourceConfig` layer (`publisher_default` /
  `atPublisher`) is unchanged — the `@`-prefix is correct there.

  **Bug fix — `search --local` reads the index (and unions the factory source).** It now
  reads the index (`asset_definitions` via `listAssetDefs`) instead of re-scanning the repo
  tree, so its result set and count match exactly what `source add`/`sync` wrote — no scanner
  `kind:name` dedup collapsing same-named or multi-kind assets — and unions the bundled
  first-party factory source (never indexed) so built-in skills still appear in a fresh
  project.

- [#339](https://github.com/skaile-ai/workspaces/pull/339) [`56776b2`](https://github.com/skaile-ai/workspaces/commit/56776b2c709d08d2aff0f46a19cd876db52db5b0) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Wire `skaile source manifest publish` and `yank` — the curator publish flow

  `source manifest publish <src>` takes a locally-curated sidecar manifest (over a
  foreign **public** GitHub repo), pushes it to a public "sidecar" repo you own,
  then registers + syncs the source with the store and publishes each curated asset
  under your namespace (default: your store username; `--as <ns>` to override, e.g.
  a GitHub org you belong to). `--dry-run` prints an offline plan (no push, no store
  calls). `yank` unpublishes the source's published assets; `propose` stays a stub.

  Requires the store's self-service `sources.add` sidecar support (skaile-store#79)
  and a deployed store backend. Mono-repo sidecars (`sidecar_path`) are a documented
  follow-up.

- [#338](https://github.com/skaile-ai/workspaces/pull/338) [`9e959ee`](https://github.com/skaile-ai/workspaces/commit/9e959eec7d3c935aaa3c6c0c001f366b2bdbd80c) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Sidecar authoring phase 3 — wire `skaile source manifest rebuild` / `verify`,
  extend the init test-fixture filter, and fix key-aware legacy clone migration.

  - **`source manifest rebuild <src>` (new verb).** Refreshes a curated manifest
    after the upstream repo changed. **Merge is the default — curation is sacred:**
    it updates the clone (`git pull --ff-only`, best-effort), re-runs discovery,
    and matches existing↔discovered entries by repo-relative `root` path. Existing
    entries whose root is still present are KEPT verbatim (all hand-edits
    preserved: publisher, per-asset name/version/description); entries whose root
    is gone upstream are DROPPED and reported (`removed (gone upstream): …`).
    Freshly-discovered assets not already curated are NOT auto-added — they are
    reported (`N new upstream asset(s) available (not added): … — add with
\`skaile source manifest edit\``), so a curated foreign-repo sidecar never
silently absorbs new upstream skills, and an asset the curator deliberately
dropped stays dropped. Writes only when something changed (else `already up to
    date`). `--overwrite`re-scans the full inventory with`init --force`
    semantics, warning that curation is replaced.
  - **`source manifest verify <src>` (new verb), read-only.** Checks each manifest
    asset against the clone: `root`/`files` paths still exist upstream (missing →
    error); a declared `sha256` on a **`files:` entry** recomputes to the same
    deterministic composite hash (mismatch → error) — hashed exactly as discovery
    does (repo-root anchored, files verbatim as repo-relative strings), so a
    correctly-pinned hash verifies clean; a declared `sha256` on a `root:` entry is
    **skipped with a warning** (discovery hashes only the per-kind filtered set, so
    a whole-dir recompute would false-mismatch, and the writer never emits sha256
    for `root:` entries); schema decodes cleanly (`decodeSkaileManifest`
    diagnostics → warnings/errors); dependency refs parse (`parseAssetRef`;
    malformed → warning). Prints a summary and exits 0 when there are no errors;
    `--strict` forces a nonzero exit if there are any warnings. Writes nothing.
  - **Extended init/rebuild test-fixture filter.** In addition to
    `tests`/`test`/`__tests__`, the filter now also drops assets whose
    repo-relative root contains a `testdata`, `fixtures`, or `__fixtures__` path
    SEGMENT (exact segment), catching leaks like
    `internal/qualitygate/skillscan/testdata/skills/lark-demo`. `init`/`rebuild`
    path only; the general discovery walk (`source add`) is untouched.
  - **Key-aware legacy clone migration.** `getGlobalCacheDir()` migration of a
    legacy `~/.skaile/sources/<bare-name>` clone now re-derives the clone's true
    cache key from its `origin` remote and lands it at
    `<cacheRoot>/sources/<host>/<owner>/<repo>` (was: preserved the bare name, so a
    URL-keyed lookup missed it and it lingered mis-placed). Non-clone leftovers
    (the sidecar remainder) are untouched. New `mergeLegacyEntriesKeyed` primitive
    in `@skaile/workspaces/core`.

  `publish` / `propose` / `yank` remain stubs (they need the store publish
  endpoint).

- [#343](https://github.com/skaile-ai/workspaces/pull/343) [`a8c2036`](https://github.com/skaile-ai/workspaces/commit/a8c2036e64c069ee46ebab3ff48e37044856b326) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Fix `skaile store login` — Keycloak device flow + correct store URL

  The CLI's store auth was broken against the deployed store: the default URL
  (`store.skaile.ai`) is dead and the old `/api/auth/*` login endpoints return 404.
  `store login` now runs the Keycloak Device Authorization Grant (RFC 8628) against
  `SKAILE_KEYCLOAK_URL` (default `https://auth.skaile.ai`, realm `skaile-store`,
  public client `skaile-store-cli`) — it prints a URL + code to approve in a
  browser, caches the token at `~/.skaile/auth.json` (shared with `store/cli`), and
  refreshes transparently. The default store API URL is now `https://api.skaile.store`;
  `whoami` reads `/api/v1/me`; every `/api/v1/*` publisher call authorizes with a
  fresh device-flow token. Overridable via `SKAILE_STORE_URL` / `SKAILE_KEYCLOAK_URL`
  / `SKAILE_STORE_REALM` / `SKAILE_STORE_CLI_CLIENT`.

  Note: live login requires a public `skaile-store-cli` client (device-grant
  enabled) registered on the store's Keycloak realm.

### Patch Changes

- [#332](https://github.com/skaile-ai/workspaces/pull/332) [`bd6e8f7`](https://github.com/skaile-ai/workspaces/commit/bd6e8f710e42017dc07afa4ab00f4134a28a038e) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Four CLI UX papercuts from hands-on testing:

  - `skaile run "<prompt>"` no longer crashes with a raw `spawn omp ENOENT` when the
    configured driver's binary is missing. The single-shot chat path now catches the
    failure and prints the bridge's actionable hint (the binary is not installed /
    not on PATH) plus a suggestion to try `--driver claude-sdk`; unrelated errors
    still surface their own message and a non-zero exit.
  - `skaile source add` warns about a missing `sha256` by pointing at the real
    `skaile source manifest rebuild` command instead of the non-existent
    `skaile source build-manifest --write`.
  - `skaile source remove` (without `--purge`) now tells the user how to reclaim the
    leftover clone — pass `--purge` or run `skaile cache clean`.
  - `skaile install <ref>` (pointer-only global install) adds a clarifying line that
    it installed globally under `~/.skaile/libraries/installed/`, and that
    `skaile add <ref>` is how to deploy the asset into the current project.

- [#328](https://github.com/skaile-ai/workspaces/pull/328) [`e613124`](https://github.com/skaile-ai/workspaces/commit/e613124bc6d5f1a46f01504a1fc239a0a905379f) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Preset UX polish from hands-on testing: `preset validate` no longer emits a
  spurious "required" warning for a required placeholder with no default (that is
  exactly what `export --redact` produces — required-ness is enforced at apply, not
  validate); `skaile init` next-steps now suggests `preset apply`; and the apply
  env-var hint points users to `.env`/shell (not skaile.yaml) so a first-time user
  applying a shared preset knows where the secret value goes.

- [#330](https://github.com/skaile-ai/workspaces/pull/330) [`be69fb8`](https://github.com/skaile-ai/workspaces/commit/be69fb816125478d7f1d2d1cc3fea8162d3f0875) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Fix `skaile search` so its store leg reaches the live catalog. The store half now
  goes through the shared catalog client (tRPC to `skaile.store`) instead of the dead
  `store.skaile.ai` REST endpoint, surfaces `catalog.url: local` and failures
  non-silently, and prints copyable `kind:@publisher/name#version` refs for both store
  and local results.

- [#337](https://github.com/skaile-ai/workspaces/pull/337) [`b0acd87`](https://github.com/skaile-ai/workspaces/commit/b0acd87f3128946fd48a120afabf5cfe262c42ef) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Collapse per-asset missing-sha256 advisories into one summary line.

  Since `source manifest init` stopped writing per-asset `sha256` for `root:`
  entries, `skaile source add` / `source sync` printed one
  `Missing sha256 — computed <hash> …` advisory line per asset. For a curated
  overlay with dozens of assets that was pure noise.

  Discovery now tags each absent-sha256 warning with a machine-readable
  `code: "missing_sha256"` (on `ReferenceWarning`) instead of relying on the
  message text, and `LocalCatalogSource.sync()` counts those and emits exactly
  one collapsed advisory:
  `<n> asset(s): sha256 not pinned — auto-computed locally (pin only for store publish)`.
  All other advisories (e.g. unknown-kind ghost-install) still print individually.
  The fatal `ManifestHashMismatch` path for a DECLARED-but-wrong sha256 is
  unchanged.

- [#336](https://github.com/skaile-ai/workspaces/pull/336) [`1d2cf89`](https://github.com/skaile-ai/workspaces/commit/1d2cf8905cd67bd5b568b046d6aff49d01cdc16d) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Sidecar polish — five papercut fixes for the `skaile source manifest` sidecar
  authoring flow, found consuming `github.com/larksuite/cli`.

  - **`init` no longer writes per-asset `sha256`.** The init writer emitted the
    discovery `sha256` (computed over the FILTERED per-kind file set) on each
    `root:` entry; `source add` then re-walked the WHOLE root dir → a different
    file set → a different composite hash → `ManifestHashMismatch`, failing every
    asset. `init` now omits `sha256` for `root:`-mode entries (a missing hash is a
    non-fatal advisory), so `source add` on init output indexes cleanly with zero
    hash mismatches.
  - **`init` skips test-fixture directories.** Assets whose repo-relative `root`
    contains a `tests` / `test` / `__tests__` path segment are dropped from the
    generated manifest (conservative, not repo-specific) so fixture "skills"
    (`scripts/*/tests/*`, `tests/cli_e2e/*`) don't pollute the curated inventory.
    `init`-path only; the general discovery walk (`source add`) is untouched.
  - **`init` next-step hint prints a runnable command; `edit` accepts the store
    slug.** The hint now echoes the exact `<src>` the user passed (the URL) instead
    of a bare short name that mis-expanded to the skaile-ai org. `resolveSourceArg`
    additionally accepts the collision-free store slug (`<host>__<owner>__<repo>`)
    as an identifier, matched against existing `~/.skaile/store/manifests/<arg>.yaml`
    before bare-name expansion. Full-URL resolution still works.
  - **`edit` respects a full `$EDITOR` command.** `EDITOR="code --wait"` now spawns
    `code` with args `["--wait", <file>]` (was treated as one binary and failed).
    New `resolveEditorCommand` splits the editor value on whitespace and appends
    the target last; unset falls back to `vi`.
  - **`preset export` warns on machine-local sidecar overlays.** When a captured
    source resolves through a curated store manifest on this machine, export now
    warns that those assets may not resolve elsewhere until the sidecar is shared.

- [#333](https://github.com/skaile-ai/workspaces/pull/333) [`d213d69`](https://github.com/skaile-ai/workspaces/commit/d213d696e308f5253c618df359e8aa0625be7c8a) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Four asset-consume UX papercuts from hands-on testing (foreign repo shipping a
  `skaile.manifest.yaml`, added via `skaile source add`, deployed via `skaile add`):

  - **P-A — publisher `@` normalization.** A manifest declaring `publisher: '@acme'`
    is now stored as `acme` (the leading `@` is the scope sigil the ref formatter
    adds automatically). Previously it indexed as `@@acme` and the asset was
    un-addable. Both manifest read paths are covered: `decodeSkaileManifest` (index
    side, emits a `warning` diagnostic) **and** `resolveSourcePublisher` / `scanRepo`
    (the `skaile search --local` scan/display side, which previously rendered the
    copyable ref as `@@acme`). Only a single leading `@` is stripped; bare names are
    untouched.
  - **P-B — missing `sha256` is an advisory, not an indexing failure.** A manifest
    asset with no `sha256` (and an unknown asset kind) now flows through the
    discovery `warnings` channel and surfaces as an `advisory` on
    `LocalCatalogSource.sync()` — never on `errors`. `skaile source add` /
    `source sync` no longer report a false "N asset(s) failed to index" or exit 1
    in this case; the asset is indexed and deployable, and the advisory is printed
    on its own info line.
  - **P-C — dead command pointer removed.** The missing-`sha256` advisory no longer
    points at the not-wired `skaile source manifest rebuild` stub; it now reads
    "only needed for store publish; auto-computed locally".
  - **P-E — search kind-miss message.** `skaile search <kind>` with no matches now
    says `No "<kind>" assets found in this project's sources.` instead of the
    misleading never-synced `Run \`skaile source sync\` first` hint.

## 1.4.0

### Minor Changes

- [#325](https://github.com/skaile-ai/workspaces/pull/325) [`a91d49c`](https://github.com/skaile-ai/workspaces/commit/a91d49cba353e1714e97faaa3fb7dde21a40304c) Thanks [@henkbla](https://github.com/henkbla)! - Protocol v3.4.0 — add the embedded-app capability source to the wire types.
  `CapabilityOrigin` gains `{ kind: 'app'; appId: string }` and `Capability.side`
  gains `'app'` ("the handler runs in the embedded app"). Both are additive and
  ride through under the protocol's major-equality rule — a peer that does not
  understand `origin:'app'` / `side:'app'` never produces or routes them, so
  existing consumers (runner, bridge, platform) are unaffected until a source
  actually registers an `app` capability. No new message types:
  `capability_register` / `capability_invoked` / `capability_result` /
  `capability_approve` / `render_invoked` are reused verbatim.

  `PROTOCOL_VERSION` bumps to `3.4.0`. `isUserInvokable` defaults `app`-origin
  capabilities to LLM-only (`false`) unless the author sets
  `audience: ['llm','user']`. The `capability` log subkind now recognizes `app`
  (with `appId` encoded into `instance` as `<appId>.<name>`).

  Design: `packages/workspaces/_devlog/specs/2026-07-07-app-capability-source.md`.
  This is the protocol keystone for Skailify (embedding agent-controllable apps in
  Skaile sessions); the runner trust guard and platform-gateway routing land in
  follow-up changes.

- [#326](https://github.com/skaile-ai/workspaces/pull/326) [`8f5e6e6`](https://github.com/skaile-ai/workspaces/commit/8f5e6e6f0fe214df35418899e3eb8747fe10fc60) Thanks [@henkbla](https://github.com/henkbla)! - Extend the runner capability-registry origin-trust guard for the `app`
  capability origin (protocol 3.4.0). `RegisterSource` gains an
  `{ kind: 'app'; appId: string }` variant, and `CapabilityRegistry.register`
  now enforces:

  - an **app source** may declare `origin.kind: 'app'` **only for its own `appId`**;
  - **no other source** (agent, client, skill, connector, mount, flow, mcp,
    framework) may declare an `app` origin — the `agent` rule is tightened to
    exclude `app` so a skill/connector/flow cannot impersonate an embedded app.

  Violations are dropped with a warn-level log, exactly as origin mismatches are
  today. This closes the forgery hole described in the app-capability-source spec
  (§ Trust / forgery rule): without it a compromised source could register
  `app.delete_everything` under a real app's id. The bare-string `'client'` /
  `'agent'` sources and all existing origin rules are unchanged.

  Also makes the registry fully `app`-aware in lock-step: `ORIGIN_PROMPT_ORDER`
  and `CapabilityListFilter.side` gain `'app'`, so an `app`-origin capability's
  `promptFragment` reaches the `<CAPABILITIES>` section and `list({ side: 'app' })`
  is expressible. The authoritative per-connection `appId` binding is enforced at
  the platform gateway (follow-up); this is the runner-side defence-in-depth.

- [#327](https://github.com/skaile-ai/workspaces/pull/327) [`fc4ed2f`](https://github.com/skaile-ai/workspaces/commit/fc4ed2fa8945484a329f5c1f5f04d9a994cb9694) Thanks [@henkbla](https://github.com/henkbla)! - Add `@skaile/workspaces/app-sdk` — the thin SDK an embedded, agent-controllable
  app uses to become a capability peer inside a Skaile session (the "Skailify"
  embed-in-platform slice; protocol 3.4.0).

  `createSkaileApp({ appId, transport })` opens an authenticated back-connection,
  performs the app handshake (`app_hello` → `app_hello_ack` binding the session to
  `appId`, or `app_hello_nack` refusal), registers `app.*` capabilities stamped
  with `origin:{kind:'app', appId}` / `side:'app'`, and answers inbound
  `capability_invoked` events by dispatching to the local handler and replying with
  `capability_result` (a thrown handler / invalid input / unknown name rides back
  as `{ error }`). Register / deregister mid-session is supported.

  Also exports `defineAppCapability` (Zod-typed authoring that fixes `side:'app'`),
  the `AppTransport` interface + app-channel message types, a browser-safe
  `createWebSocketAppTransport` (injected session identity presented as the
  `skaile-bearer.*` subprotocol the gateway already decodes), and an `AppLogger`.

  Purely additive: a new subpath export; existing consumers are unaffected. No new
  capability wire types — `capability_register` / `capability_invoked` /
  `capability_result` are reused verbatim; only the `app_hello` handshake is
  specific to this app↔gateway channel. Design:
  `packages/workspaces/_devlog/specs/2026-07-07-app-capability-source.md`.

- [#314](https://github.com/skaile-ai/workspaces/pull/314) [`d608fd9`](https://github.com/skaile-ai/workspaces/commit/d608fd9e7517662327d48123aad5a3ad080b1232) Thanks [@mortegro](https://github.com/mortegro)! - `FlowNode.type` now includes `"sub-flow"` and `"router"` alongside `"skill"` and
  `"group"`, matching node kinds already shipped in production flows (e.g.
  appbuilder-standard/-complex). `FlowNode.data` gained `flow` (delegated flow ID
  for `sub-flow` nodes) and `routes` (ordered `{condition, target}` table for
  `router` nodes). Consumers that previously cast around the narrower union can
  drop those casts.

  This is a type-level and documentation change only — the engine
  (`computeFlowState`, `computeFlowStateFromSnapshots`, `computeSkippable`) still
  tracks only `'skill'` nodes; `'sub-flow'` and `'router'` are excluded from
  dependency computation the same as `'group'` today. See
  `docs/flow-execution.md#node-types` for the full semantics of each kind and
  what's intentionally left as downstream/future work.

- [#317](https://github.com/skaile-ai/workspaces/pull/317) [`ca173c9`](https://github.com/skaile-ai/workspaces/commit/ca173c9e1617d45051a1d7fa21712660250fe13b) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Add `skaile preset apply <path>` — the consume half of the preset roundtrip. It reads a `.preset.yaml`, resolves placeholders, and writes the items back additively into the target project's `skaile.yaml` (the inverse of `preset export`). Bare-ref items become dependencies (deduped), `connector:<driver>` items become `ConnectorDeclaration`s, and `mcp:<id>` items become `McpServerDeclaration`s; a connector/mcp whose id already exists and differs is skipped (the user's config wins). Placeholder resolution is secret-safe: a `secret` placeholder resolves to an `env:<KEY>` reference (never the raw value) and the key is surfaced in a "set these env vars" summary, while non-secret `${INPUT:KEY}` tokens inline the provided literal (from `--input KEY=VAL`, an interactive prompt, or a declared default). Nested-preset refs are rejected for now. Unless `--no-deploy` is passed, the command runs the project install so new skill deps deploy. Flags: `--project-dir`, `--input` (repeatable), `--yes`, `--no-deploy`. Also adds `WorkspaceYamlEditor.addDependency` (dedupe) and `WorkspaceYamlEditor.setMcpServer` (add/replace-by-id) comment-preserving methods.

- [#316](https://github.com/skaile-ai/workspaces/pull/316) [`9c2a7ab`](https://github.com/skaile-ai/workspaces/commit/9c2a7abea4076bb79ce22b9b38f3885b7d75a513) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Add `skaile preset export [name]` — snapshot a workspace's own `skaile.yaml` into a shareable `.preset.yaml`. Dependencies map to bare-ref items (copied verbatim), connectors to `connector:<driver>` items, and MCP servers to `mcp:<id>` items; `agent_config` is deliberately excluded as personal/machine-specific. The command has first-class secret safety: it scans connector/mcp config for inline credentials (anything that is not an `env:`/`forge:`/`secret:`/… reference), prints a loud summary, redacts flagged values into `${INPUT:…}` placeholders under `--redact` (or interactively on a TTY), and stamps a `CONTAINS INLINE SECRETS` header when any inline secret is left in place — so an export is never a silent success. Flags: `--project-dir`, `--out`, `--dir`, `--redact`, `--force`.

- [#318](https://github.com/skaile-ai/workspaces/pull/318) [`c5c145c`](https://github.com/skaile-ai/workspaces/commit/c5c145ca06627da766b6df1471d626403daf3c0b) Thanks [@P1umPudding](https://github.com/P1umPudding)! - `skaile preset export`/`apply` now carry the **sources** a preset's dependencies resolve from, so a preset built against a non-factory git source (e.g. `skill:@mattpocock/grill-me` from `mattpocock/skills`) resolves on `apply` even in a workspace that never registered that source. Export captures only the needed sources — each dependency is matched to its lock entry by version-agnostic identity (kind+publisher+name) and the entry's `source.url` is recorded, excluding the bundled factory source; with no lock (or an unresolved dependency) it conservatively records every non-factory source. Apply registers those sources into `skaile.yaml` (deduped by url; an existing url with a differing pin is left untouched) before running install. Adds an optional `sources` field to the preset manifest schema and exports `FACTORY_SOURCE_URL` / `normalizeRepoUrl` from `@skaile/workspaces/asset-manager`.

### Patch Changes

- [#300](https://github.com/skaile-ai/workspaces/pull/300) [`5824f67`](https://github.com/skaile-ai/workspaces/commit/5824f671f3ca9c88adcebfe3584c03360f67e1b0) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Fix a hard crash in `skaile doctor`: `AssetManager.info()` now tolerates a bare
  `kind:name` ref (publisher optional) instead of throwing "publisher required".
  `doctor()` iterates `listDeployed()`, which — because it scans deploy dirs —
  emits publisher-less refs; the strict parse in front of `info()`'s already
  publisher-optional matching crashed on the first deployed asset. `info()` falls
  back to a minimal tolerant parse and returns `null` when it can recover neither
  a kind nor a name; the strict `parseAssetRef` is unchanged for other callers.

- [#298](https://github.com/skaile-ai/workspaces/pull/298) [`80c9d58`](https://github.com/skaile-ai/workspaces/commit/80c9d5844f8c06a89769bc7c411b69c6a63eca9b) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Fix `skaile install` never pruning manifest-less (content-sha pseudo-version)
  assets. The install reconcile derived asset identity via `parseCanonicalLockKey`,
  which rejected `#0.0.0-sha.<hash>` lock keys and returned `{}`, so a dependency
  removed from `skaile.yaml` left its deployed files behind as a silent orphan.
  `parseCanonicalLockKey` now falls back to parsing the bare identity when the pin
  is a non-canonical pseudo-version — fixing the reconcile plus every other lock-key
  consumer (locked-install restore, `list`/`globalState` state machine, `outdated`)
  for manifest-less assets. `add`/`remove` now also keep `skaile.lock.yaml` in sync
  so an added-then-removed asset can be reconciled away, and the reconcile warns on a
  genuinely unparseable lock key instead of skipping it silently.

- [#309](https://github.com/skaile-ai/workspaces/pull/309) [`f7dbdf3`](https://github.com/skaile-ai/workspaces/commit/f7dbdf341cf5f6aa6289d6d971a32183ac1cdcf3) Thanks [@mortegro](https://github.com/mortegro)! - `skaile source add` now clones/validates the repo **before** writing the source
  entry to `skaile.yaml`. Previously the entry was persisted first, so a failed
  clone (repo not found, bad URL, network error) — which hard-exits in
  `ensureClone` — left a stale, un-cloned source recorded in `skaile.yaml`. The
  entry is only written once the source is known-good.

  A `--force` re-clone now moves the existing clone aside and only deletes it once
  the fresh clone succeeds, so a failed re-clone restores the previous clone
  instead of leaving the source registered with no cache behind it.

- [#299](https://github.com/skaile-ai/workspaces/pull/299) [`f91a0dd`](https://github.com/skaile-ai/workspaces/commit/f91a0dd14f5775b76457c79f0d7ff68785fc3180) Thanks [@P1umPudding](https://github.com/P1umPudding)! - `skaile why <ref>` now distinguishes an undeclared ref from a declared-but-uninstalled one when the dependency chain is empty. Previously it always printed "not found in lock file. Run `skaile install` first." — misleading when nothing in `skaile.yaml` requires the ref, since install cannot help. Undeclared refs now get a clearer message pointing to `skaile list`; declared-but-uninstalled refs keep the install hint. The empty-chain decision is factored into a pure exported `whyEmptyMessage` helper.

## 1.3.0

### Minor Changes

- [#307](https://github.com/skaile-ai/workspaces/pull/307) [`122caeb`](https://github.com/skaile-ai/workspaces/commit/122caeb3d6d3fd8409fb451764e36d4ea0f595ae) Thanks [@peteralbert](https://github.com/peteralbert)! - Add AI cloud-provider support (`cloud` dimension) for the `claude-sdk` driver: skaile.yaml `agent-config.<profile>.cloud` (`default|bedrock|vertex|azure|gateway`) + non-secret `cloud_config`, runner-side credential env bundles from `session_init` secrets (incl. Vertex service-account JSON materialization to a 0600 file), and per-cloud SDK child-env assembly (`CLAUDE_CODE_USE_BEDROCK|VERTEX|FOUNDRY`, gateway `ANTHROPIC_BASE_URL`). `cloud: default`/absent is unchanged behavior. Model-ID validation now accepts Bedrock (`[us.]anthropic.claude-…-vN[:M]`) and Vertex (`@YYYYMMDD`) forms, with free-form IDs under `cloud: gateway` via `modelIdSchemaForCloud`.

## 1.2.0

### Minor Changes

- [#291](https://github.com/skaile-ai/workspaces/pull/291) [`aff3ab4`](https://github.com/skaile-ai/workspaces/commit/aff3ab4fdd0046987943f79bb417a52efe6ed7aa) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Catalog clients now carry report-only soft-dep refs across the wire boundary:
  `CatalogAsset` gains an optional `softDeps: string[]`, and both
  `RemoteCatalogSource` (tRPC) and `RestCatalogSource` (REST) lift the store's
  `softDepsJson` (`{ refs: string[] }` or a bare `string[]`) onto it. Forward-
  compatible: absent → `undefined`, so nothing changes until a store serves the
  field. This is the client-read half of store-side soft-dep parity ([#290](https://github.com/skaile-ai/workspaces/issues/290)); the
  store→index write that surfaces these for a store-installed asset is tracked
  separately.

- [#297](https://github.com/skaile-ai/workspaces/pull/297) [`e329cc8`](https://github.com/skaile-ai/workspaces/commit/e329cc8cd1ad236cb6e0752720f8b1dbbdd2d3b9) Thanks [@P1umPudding](https://github.com/P1umPudding)! - `skaile install` now surfaces the report-only body-link soft deps that its
  declared assets reference but that aren't present in the project — the batch
  counterpart to the `add`-time soft-dep surface, closing the last "surface at
  every lifecycle point" gap. It only ever prints a hint (never a prompt), since
  `install` is a batch / CI / clone-restore path; pulling stays an explicit
  `skaile add`. Best-effort — a soft-dep report never fails an install that
  otherwise succeeded. Applies to the project (`skaile.yaml`) install path.

- [#289](https://github.com/skaile-ai/workspaces/pull/289) [`92223ad`](https://github.com/skaile-ai/workspaces/commit/92223ad42169ffd85ea17b53c4da51828e85fc50) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Soft-dep prompt controls on `skaile add` / `skaile remove`:

  - `skaile add <ref> --with-soft-deps` — opt-in one-shot pull of the asset's
    report-only soft (body-link) deps, without prompting (CI/scripts). One level
    only (a pulled dep's own soft deps are not followed), matching the report.
  - `skaile add <ref> --no-soft-deps` and `skaile remove <ref> --no-soft-deps` —
    skip the soft-dep step entirely (no prompt, no hint), so you don't dismiss N
    prompts when you only want the primary op.
  - `skaile remove` now has a no-TTY fallback for the orphaned-soft-dep prompt: a
    piped run prints a hint listing the orphans instead of blocking on a prompt
    (symmetric with `add`). There is deliberately no batch `--with-soft-deps` on
    remove — removing on a heuristic is the dangerous direction, so orphan removal
    stays interactive (or explicitly skipped).

  Default behaviour is unchanged: soft deps stay report-only (interactive prompt on
  a TTY, printed hint otherwise).

### Patch Changes

- [#305](https://github.com/skaile-ai/workspaces/pull/305) [`5b793ab`](https://github.com/skaile-ai/workspaces/commit/5b793ab6a7f5c52427b24f231d503b3e80008137) Thanks [@peteralbert](https://github.com/peteralbert)! - Allow runner resource setup to continue after required connector startup failures so unrelated external MCP servers still start.

- [#304](https://github.com/skaile-ai/workspaces/pull/304) [`456a125`](https://github.com/skaile-ai/workspaces/commit/456a125de04f182e886a046af15cc775505632e6) Thanks [@peteralbert](https://github.com/peteralbert)! - Pass the mounted checkout as Git safe.directory for git connector operations.

- [#295](https://github.com/skaile-ai/workspaces/pull/295) [`44d764f`](https://github.com/skaile-ai/workspaces/commit/44d764f694e2fdbe51de8d93633ec250ba75e2b2) Thanks [@Frozen666](https://github.com/Frozen666)! - AgentStore: reset `phase` to `idle` when a `question` event arrives (a pending question is a human-in-the-loop pause, not active work) and restore `phase` to `thinking` in `reply()`. Fixes the chat "AI is thinking…" indicator staying visible while the agent is idle, waiting for the user to answer.

## 1.1.0

### Minor Changes

- [#292](https://github.com/skaile-ai/workspaces/pull/292) [`0297975`](https://github.com/skaile-ai/workspaces/commit/02979750e7733176ba963757629f8d1482807178) Thanks [@peteralbert](https://github.com/peteralbert)! - Add a package-side `box` filesystem connector (host fleet-managed). Cloud mounts are prepared host-side and exposed via a bind mount; the driver verifies the bind and returns a no-op lifecycle handle, mirroring the `googledrive` connector. Registered in the factory manifest and build/export entries so `driver: box` resolves in generated workspaces.

- [#275](https://github.com/skaile-ai/workspaces/pull/275) [`c58f5ab`](https://github.com/skaile-ai/workspaces/commit/c58f5ab71788e5c1b76b417ccdbab575f9b8cc12) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Discovery now detects Claude-Code-style cross-skill slash-command references
  during atomization. A standalone `/<name>` token in an asset's text (prose or
  inline code) that matches exactly one other discovered asset produces a soft
  `body-link` edge in `DiscoveryResult.graph`, alongside the existing path-based
  body-link matcher. Multi-segment paths (`/api/users`), extensions (`/foo.md`),
  non-standalone slashes (`path/x`), URLs, and ambiguous names (shared by more
  than one asset) never form an edge. The local-FS and virtual-tree discovery
  paths stay in strict parity. New exports: `extractSlashCommands` and
  `scanSlashCommandNames` from `@skaile/workspaces/discovery`.

- [#282](https://github.com/skaile-ai/workspaces/pull/282) [`31f9611`](https://github.com/skaile-ai/workspaces/commit/31f9611c3d2e16686120f096147d78a9889be50d) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Persist body-link soft-dependency edges into the local index and report them at add time.

  `LocalCatalogSource.sync()` now records each asset's body-link cross-references in a new
  `soft_deps` column on `asset_definitions` (`AssetDefinition.softDeps`). `AssetManager` gains a
  read-only `reportSoftDeps(refs)` that surfaces the soft deps an added asset references whose target
  isn't already in the project — used by `skaile add` (a dim report-only hint) and `skaile manage`
  (a status-line summary). Report-only: nothing is auto-installed and the resolver / lock / reconcile
  path is unchanged. Existing index databases migrate in place via an idempotent `ADD COLUMN`.

- [#287](https://github.com/skaile-ai/workspaces/pull/287) [`aee0819`](https://github.com/skaile-ai/workspaces/commit/aee081930b46ade972fa6b1ab5ff236abbc66343) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Surface body-link soft dependencies across the asset lifecycle (display + prompt, report-only).

  Builds on the persisted `softDeps` / `reportSoftDeps` foundation with three additive surfaces:

  - **info / tree / why** — `skaile info <ref>` now prints a "Soft dependencies (inferred)" section
    (distinct from typed `requires`); `skaile tree` and `skaile why` list the inferred soft edges in a
    clearly-marked `(soft)` footer. Backed by a new read method `AssetManager.softDepsOf(refs)` that
    returns an asset's full inferred soft-dep set regardless of project presence.
  - **add prompt** — `skaile add <ref>` now **prompts** (default No, never auto-adds) to also include
    each absent body-link soft dep it references (`<from> references <to> (soft dependency) — also add
it?`), symmetric with the remove-orphan prompt below. On yes the target is added; a non-interactive
    run (no TTY) falls back to printing the report-only hint instead of prompting, so a piped `skaile
add` never hangs. The deferred `--with-soft-deps` "yes-to-all" flag stays out of scope.
  - **manage live-preview** — highlighting an asset in the `manage` TUI previews its soft deps in the
    footer: an absent asset shows `<name> references <x> (soft dep, not auto-added)`; a present/removable
    asset shows `removing <name> would orphan <x> (soft dep)`. Computed on highlight/selection change
    (not per frame), memoized per (mode, ref), and never auto-added/removed.
  - **remove-orphan prompt** — `skaile remove <ref>` now prompts (default No, never auto-removes) to
    also remove a soft-dep target that came in for the removed asset and that nothing else still needs.
    Backed by two new read methods: `AssetManager.softDepDependents(targetRef)` (reverse lookup of
    assets whose `softDeps` include the target) and `AssetManager.orphanedSoftDepsAfterRemoving(refs)`
    (orphan computation considering both soft-dep and typed `requires` referrers). The same orphan
    summary is surfaced in `manage`'s apply path.
  - **tree / why under content-sha locks** — the soft-dep lookup now matches the index def
    version-agnostically, so `skaile tree` shows the `(soft)` footer and `skaile why <kind:@pub/name>`
    resolves for a real locked asset pinned under a content-sha pseudo-version (`#0.0.0-sha.<hash>`,
    not valid SemVer) that differs from the index's canonical semver. `why` also resolves a bare
    `kind:@pub/name` ref to its sha-pinned lock entry.

  Report-only throughout: nothing is auto-installed or auto-removed except the explicit add-on-yes
  opt-in, and the resolver / lock / reconcile path is unchanged. `AssetManager.remove()`'s signature is
  unchanged — orphan info is exposed via the separate read method.

- [#284](https://github.com/skaile-ai/workspaces/pull/284) [`578f354`](https://github.com/skaile-ai/workspaces/commit/578f354fe7ae99c9a8773f00d6c45191b3604924) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Unify how a source's publisher is derived so the index (`sync`/discovery) and
  resolution (`add`/`resolveAll`) agree on a single name. A manifest-less source
  (no `publisher` in `skaile.manifest.yaml`) is now indexed under its GitHub org —
  the same fallback the provenance walker uses — instead of the first path segment.
  Previously `mattpocock/skills` indexed `@skills/grill-me` while `add` resolved
  `@mattpocock/grill-me`, so the two never matched.

  - `@skaile/workspaces/core` now exports `resolveSourcePublisher(repoDir)` (the
    shared `manifest publisher ?? GitHub org` helper). The GitHub org is lowercased
    to its canonical slug through one shared rule (`normalizeGithubOrg`) used by
    both the index side (`resolveSourcePublisher` / `scanRepo`) and the resolve
    side (`buildProvenanceIndex` in the walker), so a cased org (`Acme`) resolves
    identically to its lowercase index ref (`@acme`) — no case split. A declared
    manifest `publisher` is returned verbatim on both sides (already in parity).
  - `LocalCatalogSource.sync()` injects that publisher when the manifest declares
    none, and prunes stale rows for the source so a re-keyed def leaves no orphan
    (the prune is skipped on an empty discovery to avoid a silent wipe).

  Behaviour change: manifest-less sources now publish under the GitHub org
  (lowercased), not the path segment. Re-run `skaile source sync` for any affected
  source. See `MIGRATION.md`.

### Patch Changes

- [#293](https://github.com/skaile-ai/workspaces/pull/293) [`b538c80`](https://github.com/skaile-ai/workspaces/commit/b538c8081abb6dfbe4360254d76353b562214499) Thanks [@peteralbert](https://github.com/peteralbert)! - Retire the outgoing agent driver on every mid-session recreate. `agentSession.dispose()`
  only tears down resources, not the driver, so each recreate (tier-1 native resume,
  subagents recreate, flow-connector registration, mid-session skill deploy) left the
  previous `ClaudeSdkDriver` alive — keeping its live SDK query, detached consume loop,
  and attached `agent-event` forwarder. The leaked driver then double-streamed every
  subsequent turn into the shared sink, and the platform gateway (which concatenates
  streamed text with no dedup) persisted two interleaved copies, producing garbled
  assistant messages with repeated fragments and broken markdown from that point on.

  A new `swapDriver()` helper silences (`removeAllListeners`) and `kill()`s the outgoing
  driver before adopting `agentSession.driver`, guaranteeing a single live producer per
  session. All three recreate sites now route through it.

## 1.0.0

### Major Changes

- [#280](https://github.com/skaile-ai/workspaces/pull/280) [`84a2b35`](https://github.com/skaile-ai/workspaces/commit/84a2b35eec330b094732bb4c947ab58137ab45ed) Thanks [@peteralbert](https://github.com/peteralbert)! - Remove the legacy in-container rclone cloud mount path. SharePoint, WebDAV, and
  Google Drive connectors now require host fleet-managed bind mounts listed in
  `SKAILE_FLEET_MANAGED_MOUNTS`; package-side connectors only verify the bind and
  return no-op lifecycle handles.

  The `@skaile/workspaces/connectors/rclone` and
  `@skaile/workspaces/connectors/rclone-config` subpath exports, rclone process
  manager, rclone token refresh controller, mount placeholder barrier, and fleet
  health gate are no longer shipped.

## 0.53.0

### Minor Changes

- [#277](https://github.com/skaile-ai/workspaces/pull/277) [`6a914a2`](https://github.com/skaile-ai/workspaces/commit/6a914a2ae40fb7be4463334e3e5dbb3faaf97738) Thanks [@peteralbert](https://github.com/peteralbert)! - Enable in-container OAuth token refresh for per-session SharePoint and WebDAV mounts. `connectorRefreshKind` now maps `sharepoint`→`sharepoint-mount` and `webdav`→`webdav-mount` (in addition to `git`), so the connector token mediator routes their proactive/reactive refreshes to the platform's `host.refresh_credential` handler instead of refusing them. The rclone drivers already carried the apply path (re-render INI + stop/respawn), so these mounts no longer go stale (`invalid_client`) ~1h after wake. `googledrive` remains excluded until its driver gains a refresh controller.

## 0.52.0

### Minor Changes

- [#271](https://github.com/skaile-ai/workspaces/pull/271) [`4e1c9bf`](https://github.com/skaile-ai/workspaces/commit/4e1c9bf836b4f403b8bcd6115a910419fb44afc2) Thanks [@mortegro](https://github.com/mortegro)! - `skaile update` now offers to sync sources from upstream before re-deploying
  (default yes). Re-deploy resolves against the local source cache, which is frozen
  at the commit captured when each source was first cloned — so without a sync,
  `update` could never see newer GitHub commits on an unpinned source and always
  reported "Nothing to update" (the catalog-refresh stage misled users into
  thinking it had checked).

  `update` now runs a Stage 2a source sync behind a confirmation prompt before the
  asset re-deploy. The prompt defaults to yes; `--no-sync` skips it, `-y`/`--yes`
  syncs without asking, and a non-interactive shell syncs by default. The sync
  clones missing sources and advances existing clones with `git pull --ff-only`
  (no `--depth=1`, so it fast-forwards instead of tripping the installer's
  shallow-divergence path), then refreshes the manifest cache.

  New export from the CLI: `syncProjectSources(projectDir, { dev? })`, factored out
  of `autoSyncForSearch` and shared by both call sites.

### Patch Changes

- [#273](https://github.com/skaile-ai/workspaces/pull/273) [`f689c55`](https://github.com/skaile-ai/workspaces/commit/f689c55cb21a5e52a8a8d00d9ee53738fe15547b) Thanks [@mortegro](https://github.com/mortegro)! - Fix flow installs not pulling their dependencies. Installing a `flow:` asset
  (e.g. `skaile install` of a flow declared in `skaile.yaml`) deployed only the
  bare `.flow.yaml` and none of the contracts / skills / sub-flows the flow
  declares under `requires:`. The provenance walker only read a `requires`/
  `dependencies` list for `bundle` kinds (`bundleDeps`), so a flow's transitive
  closure was always empty. `manifestDeps` now also reads a flow's `.flow.yaml` /
  `.flow.json` `requires:`, and `bundleDepRefs` whole-document-parses those flow
  files. Sub-flow requires expand recursively, so installing a top-level flow now
  deploys its full dependency tree.

- [#268](https://github.com/skaile-ai/workspaces/pull/268) [`1739497`](https://github.com/skaile-ai/workspaces/commit/1739497dd081f07868e4aee2231b9586d0afd80c) Thanks [@peteralbert](https://github.com/peteralbert)! - Fix a resumed session's workspace being unreadable (`readResource` → 503
  "runtime not reachable") on the tier-1 native-resume / subagents-recreate path.
  `recreateSessionForInit` disposed and recreated the agent session but never
  reassigned the outer `resourceManager` (so the resource-capability getters and
  reads kept hitting the disposed manager) and never re-announced resources, so
  the gateway never learned the recreated session's live resources. The recreate
  now mirrors the first-build wiring: it repoints `resourceManager` at the live
  session, resumes watching, and re-emits `resources_available` /
  `commands_available`. The connect-time emitter is renamed
  `emitAvailabilityOnConnect` → `emitAvailability` (it is now also invoked after a
  recreate, not only on client connect).

## 0.51.0

### Minor Changes

- [#252](https://github.com/skaile-ai/workspaces/pull/252) [`3b0c1bd`](https://github.com/skaile-ai/workspaces/commit/3b0c1bd5dedfdcc41a4a5633c0678d4e79d30b39) Thanks [@mortegro](https://github.com/mortegro)! - Reference-aware asset atomization in discovery. The structural file filters that
  decide which files belong to one skill/agent/etc. were purely path-based and
  silently dropped helper files an asset referenced from non-conventional
  locations (e.g. a skill linking `modes/*.md` or `scripts/*.ts`).

  Four layered mechanisms, none of which use an LLM:

  1. **Reference lint (always on)** — discovery scans each asset's text for
     path-like references and reports any that resolve to a real in-dir file the
     filter dropped, via the new `DiscoveryResult.warnings`.
  2. **Convention companion dirs** — the skill/agent filters now capture a broader
     allowlist of well-known helper subdirs (`scripts/`, `modes/`, `templates/`,
     …), mirrored in the virtual-tree path so store/local SHA256 parity holds.
  3. **Reference closure (`atomization: "closure"`)** — transitively pulls in
     referenced in-dir files. Opt-in.
  4. **Full-dir fallback (`atomization: "full-dir"`)** — captures the whole asset
     directory minus nested sub-assets. Opt-in.

  Plus a dependency post-pass that turns cross-asset prose references into soft
  `body-link` requires edges. The `atomization` option is honored on **both** the
  local-FS (`discoverAssetsInTree`) and virtual-tree (`discoverAssetsInTreeEntries`)
  paths at exact SHA256 parity — both share the scan/closure logic via a new
  `AssetFs` abstraction.

  New `atomization` option on `DiscoveryOptions` + `DiscoverAssetsInTreeEntriesOptions`
  (default `"strict"`). New exports: `AtomizationPolicy`,
  `DEFAULT_ATOMIZATION_POLICY`, `scanAssetReferences`, `lintUncoveredReferences`,
  `extractPathCandidates`, `closeOverReferences`, `createNodeAssetFs`,
  `SKILL_COMPANION_DIRS`, `AGENT_COMPANION_DIRS`, and the `AssetFs` /
  `ReferenceWarning` / `AssetReference` / `ClosureResult` / `ReferenceClass` types.
  `DiscoveryResult` gains a required `warnings: ReferenceWarning[]` field.

- [#256](https://github.com/skaile-ai/workspaces/pull/256) [`3609659`](https://github.com/skaile-ai/workspaces/commit/36096594d71275940bbdcb219ec5da7b46d9a3c4) Thanks [@mortegro](https://github.com/mortegro)! - Global cross-backend install path ([#206](https://github.com/skaile-ai/workspaces/issues/206)). `skaile install --global` now fans the
  deps declared in the user-scope SSOT (`~/.skaile/skaile.yaml`) out to **every**
  global backend the user runs (`claude-code` / `omp` / `codex`), each landing in
  its `DRIVER_TARGETS[target].global` dir, instead of being bolted onto a single
  project-scoped target.

  In global mode the manager's state — config SSOT, lock, cache, and history —
  roots at `~/.skaile`: a global lock is written to `~/.skaile/skaile.lock.yaml`
  (not the project lock), and staging/fetch goes through `globalCacheRoot()`
  (`~/.skaile/cache`) so a global asset is not re-fetched per project. The old
  `if (this.global) return;` suppression in `AssetManager`'s manifest writers is
  lifted: `skaile add --global <ref>` / `skaile remove --global <ref>` now record
  into `~/.skaile/skaile.yaml` (bootstrapping it on a fresh machine), routing the
  edit to the SSOT rather than `<projectDir>/skaile.yaml`.

  The backend set is resolvable: an explicit `global_backends:` key in
  `~/.skaile/skaile.yaml`, else auto-detected from existing `~/.<backend>` dirs. A
  `--backend <ids>` filter (comma-separated, on `install` / `add` / `remove` /
  `list`) narrows the fan-out to a subset and implies `--global`.

  `skaile list --global` reports reconciliation state **per backend** —
  `declared` / `installed` / `drift` (the [#205](https://github.com/skaile-ai/workspaces/issues/205) vocabulary, now backend-aware), so
  an asset deployed to `claude-code` but not `codex` reads `installed` for the
  former and `drift`/`declared` for the latter. `--json` emits
  `{ ref, kind, name, backend, state }`. Project and global scopes stay isolated:
  a project `skaile.yaml` dep deploys into `.claude/` and never touches the global
  `~/.claude/` deploy.

  New exports:

  - `@skaile/workspaces/core`: `globalSkaileDir`, `globalBackendRoot`,
    `resolveGlobalBackends`, `resolveGlobalSkWorkspaceConfig`; `SkWorkspaceConfig`
    gains an optional `global_backends?: DriverTarget[]` field.
  - `@skaile/workspaces/asset-manager`: `AssetManager.globalState(opts?)` returning
    `BackendDeclaredAsset[]`; new `AssetManagerOptions.backends`.

  Additive — no breaking changes to existing surfaces.

- [#255](https://github.com/skaile-ai/workspaces/pull/255) [`4a3e756`](https://github.com/skaile-ai/workspaces/commit/4a3e7565f00cb00e86f7d6bb00d3b07e60827b69) Thanks [@mortegro](https://github.com/mortegro)! - `skaile manage`: group on-disk assets by domain. The source / library / factory
  feeds scan with `scanRepo`, which never read the `DOMAIN.md` sidecars, so their
  entries arrived with no `domainSlug` and rendered flat under one publisher band
  (e.g. every `@skaile-ai/*` asset stuffed under `skaile-ai` with no subgroups) —
  only the remote store feed carried domains. The feed layer (`asset-feeds.ts`)
  now parses each root's `DOMAIN.md` files and stamps `domainSlug` per entry
  (reusing `@skaile/workspaces/discovery`'s `parseDomainMd` / `assignDomain` /
  `buildDomainIndex`), falling back to the source's short repo name for entries no
  domain claims. Adds `AssetManager.searchRepoRoots()` so the feed can relate a
  scanned entry back to its source root.

- [#266](https://github.com/skaile-ai/workspaces/pull/266) [`94dec67`](https://github.com/skaile-ai/workspaces/commit/94dec67b595c1f998ec0ce8140da33879495fd16) Thanks [@mortegro](https://github.com/mortegro)! - `skaile manage`: the **Sources** tab is now a collapsible tree instead of a flat
  list. Registered github sources render as `Sources → org → repo → domain → asset`
  — the source-origin slice of the Assets feed with an added github-**org** band
  (e.g. `Sources → skaile-ai → ai-assets-skaileup → <domains>`). Domains default
  **collapsed** (collapse-at-domain), so you land on the org/repo/domain skeleton
  and drill in as needed.

  The tab supports the same install/remove flow as Assets: `←/→` collapse/expand,
  `space/+/-` to stage (org and repo headers bulk-select their subtree; a domain
  header is navigational), `a/Enter` to apply, `i` for info, `d` for the domain
  info region. Source CRUD stays in the shell (`skaile source …`). Sources with no
  discoverable assets still appear as an empty org/repo band so a freshly-added
  source is visible. The Assets-tab tree is unchanged.

- [#254](https://github.com/skaile-ai/workspaces/pull/254) [`3034e45`](https://github.com/skaile-ai/workspaces/commit/3034e45f1c299bfe2693c1967a81000f515e6b95) Thanks [@mortegro](https://github.com/mortegro)! - Remove the deprecated `resolveAuth` shim from the `@skaile/workspaces/connectors`
  public surface. Connectors now resolve credentials through
  `SecretProviderChain.resolve()` directly — `ctx.secrets?.resolve(declaration.auth ?? "")`
  in place of `resolveAuth(declaration.auth, ctx.secrets)`. All built-in connectors
  (s3, minio, sqlite, postgres, gmail, mattermost) and the internal
  `resolveMountCredential` path are migrated; the external `@skaile/connector-redis`
  plugin is migrated in lockstep.

  Behaviour is **identical whenever a `SecretProvider` is wired** (the production
  path — the runner always builds a chain via `createCliSecretProviderChain` /
  `createForgeSecretProviderChain`): `resolveAuth(x, secrets)` already delegated to
  `secrets.resolve(x)` in that case, so `env:` lookups and the CLI chain's inline
  passthrough are unchanged. The only contract change is the removed shim's
  no-provider fallback — when `secrets` was `undefined`, `resolveAuth` did a direct
  `process.env` lookup for `env:` refs and returned any other value as a raw
  literal; `secrets?.resolve(...)` now yields `undefined` in that case. See
  `MIGRATION.md`.

### Patch Changes

- [#265](https://github.com/skaile-ai/workspaces/pull/265) [`e30e31f`](https://github.com/skaile-ai/workspaces/commit/e30e31fc00f678fb2f9f8e796a8e8687b84153f1) Thanks [@mortegro](https://github.com/mortegro)! - Fix `ERR_FS_EISDIR` crash on `skaile add` / `install` when a catalog entry
  resolved to a clone directory instead of a manifest file. `candidateToCatalogEntry`
  now resolves a concrete manifest path via the suffix-aware `matchManifestPath`
  (covering `prompt`/`flow`), falling back to the candidate's first file rather than
  the whole clone dir. As a backstop, `copyFromManifest` / `symlinkFromManifest`
  now copy a directory source recursively instead of throwing.

- [#270](https://github.com/skaile-ai/workspaces/pull/270) [`79e7211`](https://github.com/skaile-ai/workspaces/commit/79e7211a40db89de4e507d39dedfd400d1ed9aed) Thanks [@mortegro](https://github.com/mortegro)! - Fix flow dependency extraction reading the wrong node field. The flow
  requires-graph extractor (`discovery` + the flow kind provider) read the
  skill ref from the top-level `nodes[].skill`, but real flows nest it under
  `nodes[].data.skill` (the shape the engine executes via
  `node.data?.skill ?? node.id`). The mismatch meant installing a flow pulled
  zero skills. Extraction now reads `nodes[].data.skill` (falling back to the
  legacy top-level `nodes[].skill`) and also picks up sub-flow refs from
  `nodes[].data.flow`.

- [#269](https://github.com/skaile-ai/workspaces/pull/269) [`b4daa33`](https://github.com/skaile-ai/workspaces/commit/b4daa3357ca151d6bfa7f3b428d3689d831563ff) Thanks [@mortegro](https://github.com/mortegro)! - `skaile manage` Assets tab domains now collapse by default on first open. Previously all domain sections were expanded, resulting in an overwhelming list for sources with many domains (e.g. 15 domains / 105 assets). A user expand within the session is preserved across background data refreshes.

- [#264](https://github.com/skaile-ai/workspaces/pull/264) [`f770d53`](https://github.com/skaile-ai/workspaces/commit/f770d534c2022f82fd832a114bee0e852738d6e9) Thanks [@mortegro](https://github.com/mortegro)! - `skaile manage`: group the Sources band by source, and stop listing factory
  skills twice. Two fixes to the Assets-tab tree:

  - **Group by source under Sources.** The second-level node under the 🔗 Sources
    band was the asset-intrinsic `publisher` (every `@skaile-ai/*` source stuffed
    under one `skaile-ai` band). It is now the source's short repo name — "which
    repo did this come from" — which is what the Sources band is about. Other
    origins (library / factory / store) still group by publisher. Carried on a new
    optional `OriginEntry.group` and threaded onto both header and asset rows so
    collapse/selection keys stay consistent; the canonical install ref still uses
    `publisher`.
  - **No duplicate factory.** The bundled factory tree is injected into the
    repositories map as an implicit source, so `am.search()` returned it too and
    the first-party skills appeared under **both** the Sources band and the
    dedicated 🏭 Skaile Factory Assets band. The source feed now drops entries
    whose path is under `factoryAssetRoots()`, leaving them only in the factory
    band. An explicitly `source add`ed factory-URL clone is unaffected.

  Source-feed entries that match no `DOMAIN.md` now hang directly under the source
  node instead of a synthetic same-named fallback domain (avoids `source → ◈ same
source` self-nesting). `stampDomains` gains a `synthesizeFallback` flag for this.

- [#257](https://github.com/skaile-ai/workspaces/pull/257) [`e4314d6`](https://github.com/skaile-ai/workspaces/commit/e4314d670e9d960a7f774e2bb6b9f586bfa0e25f) Thanks [@mortegro](https://github.com/mortegro)! - `skaile search` robustness ([#208](https://github.com/skaile-ai/workspaces/issues/208)). The store-unavailable case is no longer
  silently swallowed on a default search — it degrades to local results but prints
  a one-line `store unavailable …` notice (explicit `--store` still surfaces the
  real error). `search` now honours air-gapped mode (`catalog.cache_ttl: 0`): it
  makes no store network call and says so, and `--store` errors clearly instead of
  fetching. On an unsynced workspace, a default `search` auto-syncs the project's
  sources and re-queries (with a `sources not synced — syncing now…` message)
  instead of just telling the user to run `skaile source sync`; auto-sync is
  skipped when air-gapped and falls back to the old hint on failure.

- [#250](https://github.com/skaile-ai/workspaces/pull/250) [`53d2e48`](https://github.com/skaile-ai/workspaces/commit/53d2e48347ef0a9daf304106e97ba4948eabac3b) Thanks [@Frozen666](https://github.com/Frozen666)! - Promote `listen()` / `close()` onto the `ServerTransport` interface so any
  transport (WebSocket today, broker-relayed later) can be started/stopped
  polymorphically. The runner's `serve()` no longer `instanceof`-gates the
  WebSocket transport for listen/close/onConnect; in-process and in-memory
  transports implement the two methods as no-ops. WebSocket behavior is
  unchanged. Prerequisite for the platform session-network-isolation transport.

  Behavior note: dropping the `onConnect` instanceof guard means a `serve()` run
  driven over the in-memory test transport now emits `commands_available` /
  `resources_available` on client connect (it mirrors the WebSocket path). The
  in-process transport's `onConnect` still never fires, so it is unaffected.

## 0.50.0

### Minor Changes

- [#246](https://github.com/skaile-ai/workspaces/pull/246) [`b91e41b`](https://github.com/skaile-ai/workspaces/commit/b91e41bd84549d189a4533d5d634ada4a857f0c7) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Deploy assets by copy by default. Previously local-source assets were symlinked
  into the global cache, which broke on `cache clean` and made committed `.claude/`
  non-portable (machine-local absolute paths). Deploys are now self-contained copies
  for sources, local libraries, store, and factory alike. A new `--link` opt-in
  (`skaile add --link` / `skaile install --link`) restores the symlink dev-loop for
  local-path sources only (no-op for store/factory). Re-deploying over an existing
  symlink heals it into a real copy.

### Patch Changes

- [#248](https://github.com/skaile-ai/workspaces/pull/248) [`de19986`](https://github.com/skaile-ai/workspaces/commit/de19986cd70e287041a146335efc0b0929718973) Thanks [@peteralbert](https://github.com/peteralbert)! - `AgentStore` now flushes in-flight streaming text to its own Message at each
  mid-turn boundary event (`tool_call`, `tool_result`, `subagent`, `file_changed`,
  `dev_server`, `handoff`, `question`, `question_reply`) instead of merging text
  from before, between, and after tool calls into one `_streamingText` blob.
  Each assistant text block becomes a separate `text` Message, interleaved with
  tool activity in chronological order, so consuming UIs render distinct steps
  rather than one growing wall of text.

## 0.49.0

### Minor Changes

- [#242](https://github.com/skaile-ai/workspaces/pull/242) [`a476838`](https://github.com/skaile-ai/workspaces/commit/a476838df093016bffbcbc29be75c0868b82a21a) Thanks [@Frozen666](https://github.com/Frozen666)! - runner: emit a `runtime_session` event carrying the driver SDK session id (+ model + capability signature) on first-known and on change

  Surfaces the tier-1 native-resume token so the platform can persist it on the Session independently of compaction. Previously the resume token was captured only as a side effect of a compaction, so a session that never compacted and only auto-hibernated had no stored token and fell back to lossy raw-tail resume on wake even though its JSONL survived. Additive to the `AgentEvent` union — existing consumers ignore the new event.

### Patch Changes

- [#244](https://github.com/skaile-ai/workspaces/pull/244) [`21558f6`](https://github.com/skaile-ai/workspaces/commit/21558f6ab3768dccbab22d55cb6d76c71952027d) Thanks [@Frozen666](https://github.com/Frozen666)! - runner: re-emit `runtime_session` on model/capability-signature drift, not only on session-id change

  A mid-session capability shift (e.g. a live skill add) or model change that does not recreate the driver previously left the platform's stored wake-time guards stale, needlessly dropping a still-valid session to lossy tier-2 resume on wake. The runner now re-emits whenever the session id, model, OR capability signature changes. Also short-circuits before hashing the capability signature when no session id exists yet, so early no-session turns don't pay for the compute.

## 0.48.2

### Patch Changes

- [#241](https://github.com/skaile-ai/workspaces/pull/241) [`23aed4b`](https://github.com/skaile-ai/workspaces/commit/23aed4b297cc01736bfcc9e01775ae68e39c8161) Thanks [@peteralbert](https://github.com/peteralbert)! - Fix git mount token refresh failing with `not-configured` ("connector is missing
  a providerLinkId; cannot refresh"). The 0.48.0 runner-side refresh mediator runs
  but the skaile.yaml boot path stripped the connector's `providerLinkId` before it
  reached the mediator: the core `ConnectorDeclaration` did not declare the field,
  so `workspaceConnectorToDeclaration` (and the materialized-CONNECTOR.md loader)
  silently dropped it. The platform serializes `providerLinkId` top-level on
  backend-auth git connectors, so the value was present in config but lost in the
  mapping, and `host.refresh_credential` was never invoked — the git token never
  rotated and the agent's raw `git push` failed ~1h after wake. `providerLinkId`
  now survives both the skaile.yaml and materialized boot paths. The hot-plug path
  (which nested it under `options`) was unaffected.

- [#235](https://github.com/skaile-ai/workspaces/pull/235) [`f895e7b`](https://github.com/skaile-ai/workspaces/commit/f895e7b496a001d51ba03ae26fe2945e9572f0fc) Thanks [@Frozen666](https://github.com/Frozen666)! - fix(bridge): answer AskUserQuestion via the SDK `canUseTool` round-trip instead of re-asking

  The `claude-sdk` driver treated `AskUserQuestion` as an ordinary `tool_use`, so the query was never paused — the model kept taking turns and re-asked the same question several times before any reply, and the reply was delivered as a plain prompt rather than the structured answer. The driver now wires the SDK `canUseTool` callback to pause on `AskUserQuestion` and resume with the user's answer (`hasPendingQuestion()` / `answerQuestion()` on `AgentDriver`; routed by the runner's reply handler). A pending question is denied cleanly on abort/hibernate so the resumed transcript has no dangling `tool_use`.

- [#239](https://github.com/skaile-ai/workspaces/pull/239) [`65d5b1e`](https://github.com/skaile-ai/workspaces/commit/65d5b1e1c4b1ee4a36923dce6dfd43ce771885d3) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Fix `skaile source add` failing to index a valid source repo. Index DBs upgraded
  through the old `sources → libraries` migration carried a stale
  `asset_definitions.library_id` foreign key still referencing the dropped
  `sources` table, so every upsert failed under `PRAGMA foreign_keys = ON` with
  `SQLITE_ERROR: no such table: main.sources` — surfaced only as an opaque
  `Failed query: <SQL>`. `createLibraryDb` now heals the stale FK on open by
  rebuilding the table, the catalog sync error now includes the underlying cause,
  `skaile source add`/`sync` no longer report success when indexing partially
  fails (exit code 1), and a hint is emitted when a source ships only a legacy
  `.skaile-source.yaml` without a `skaile.manifest.yaml`. The partial-failure gate
  keys on real indexing errors rather than the discovered-vs-indexed count, so a
  source that ships a `.preset.yaml` (counted but intentionally not upserted) no
  longer trips a false `failed to index` warning + non-zero exit.

- [#238](https://github.com/skaile-ai/workspaces/pull/238) [`fc0ee47`](https://github.com/skaile-ai/workspaces/commit/fc0ee4780c93281973992c1999f3c89963874dfa) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Fix four small install/init issues:

  - **`install` no longer spuriously removes a still-declared asset.**
    `reconcileRemovedAssets` built its "still resolved" set as
    `<publisher>/<kind>:<name>@<version>` but compared it against the lock keys,
    which are `<kind>:@<publisher>/<name>#<version>` (per `buildLockFile`). The
    formats never matched, so every locked asset was reported `! Removed` and
    needlessly undeployed + redeployed on each repeat `install`. Both sides now use
    the canonical lock-key format.
  - **`install`/`add` now restore a deleted manifest file.** `deployAll` only
    `lstat`'d the deployed _directory_ to decide an asset was already deployed, and
    its `catch` swallowed the `ENOENT` thrown when hashing a missing manifest —
    so deleting just `.claude/skills/<name>/SKILL.md` (leaving the dir) made
    `install`/`add` report "already up to date" and never re-create the file. The
    hash compare now guards on `existsSync(deployedManifest)` first; a missing
    manifest falls through to a re-deploy. Symlinked installs (which resolve
    through to the live source) and content-hash mismatches are unchanged.
  - **`skaile init` writes a complete `.gitignore`.** Added the skaile-created
    artifacts `.skaile/logs.db*`, `.skaile/settings.json` (holds personal API
    keys), and `.skaile/history.yaml`, closing a secret-leak gap — `settings.json`
    was documented as gitignored but wasn't.
  - **`skaile init` "Next steps" now mentions `skaile add`.** The post-init hint
    surfaces the ergonomic `skaile add <ref>` (add + deploy in one step) and
    `skaile search`, not only the manual edit-`skaile.yaml`-then-`install` path.

## 0.48.1

### Patch Changes

- [#233](https://github.com/skaile-ai/workspaces/pull/233) [`a3e523c`](https://github.com/skaile-ai/workspaces/commit/a3e523c2326b5fc0c8d35d15f9cef1af30b1230e) Thanks [@peteralbert](https://github.com/peteralbert)! - fix(rclone): apply the SharePoint single-part upload cutoff to fleet mounts.

  `_buildRcloneArgs` gated `--onedrive-upload-cutoff 4Mi` on
  `driverName === "sharepoint"`, but the platform fleet path passes
  `"sharepoint+fleet"` (the `+fleet` tag drives the `${driverName}+rclone`
  child-logger subkind). The exact-string match silently dropped the cutoff on
  every shared/fleet-managed SharePoint mount — precisely the heavily-overwritten
  mounts — so in-place file UPDATES kept hitting the resumable-upload-session
  `itemNotFound` 404 and write-backs stuck forever (local mount showed the new
  bytes, SharePoint kept the old). Match the base driver before any `+` tag so the
  cutoff reaches both the per-session and the fleet path.

## 0.48.0

### Minor Changes

- [#230](https://github.com/skaile-ai/workspaces/pull/230) [`334bdc8`](https://github.com/skaile-ai/workspaces/commit/334bdc83ae000c3c6c9ec9bc0d99a32687701d12) Thanks [@peteralbert](https://github.com/peteralbert)! - Runner: wire a live connector token-refresh mediator so a backend-auth git mount's token rotates via `host.refresh_credential` (passing the provider link id) instead of failing `not-configured`. Previously the runner pre-minted the token once at wake and never supplied the refresh mediator, so the agent's `git push` 401'd ~1h after each wake.

## 0.47.3

### Patch Changes

- [#227](https://github.com/skaile-ai/workspaces/pull/227) [`2765b64`](https://github.com/skaile-ai/workspaces/commit/2765b64124f805f73b017fbd86927cf1e126cdd9) Thanks [@peteralbert](https://github.com/peteralbert)! - Carry the connector connection name (`label`) from a materialized `CONNECTOR.md`
  into the `resources_available` wire payload. `fromMaterializedConnectorMd` now
  parses `meta.label`, `ConnectorDeclaration`/`ConnectorInfo`/`MountInfo` gain an
  optional `label`, and `splitConnectorsForWire` emits it on both mounts and tool
  connectors — so library-assigned (asset-materialized) connectors show their
  connection name in the platform resource-explorer tab instead of the driver name.
  Backward-compatible: old consumers ignore the new optional field.

## 0.47.2

### Patch Changes

- [#224](https://github.com/skaile-ai/workspaces/pull/224) [`d1f01b1`](https://github.com/skaile-ai/workspaces/commit/d1f01b1f7984f6857ddfa8576e98d5f8e1d6a9b5) Thanks [@peteralbert](https://github.com/peteralbert)! - fix(runner): re-stage materialized skills on each prompt, not only at container boot. A skill the platform materializes after boot (a fresh session whose first materialize lands late) is now staged into `.claude/skills/` on the next prompt and the driver restarts to discover it, instead of being missed forever on a long-running session that never re-wakes.

## 0.47.1

### Patch Changes

- [#221](https://github.com/skaile-ai/workspaces/pull/221) [`43f390d`](https://github.com/skaile-ai/workspaces/commit/43f390db2d29c23c161fb2af53f5d75ad92a3227) Thanks [@peteralbert](https://github.com/peteralbert)! - fix(git): Tier-1 mounts now refresh the baked origin token on a timer

  Tier-1 git mounts (no `exposeAccessToken`) only re-baked the freshly minted
  token into `origin` at connect time. A session that stays awake past the
  GitHub App token's ~1h TTL never re-baked, so the agent's raw `git push`
  started failing with an expired token. Tier-1 backend-mediated mounts now
  schedule the same pre-expiry rotation tick as Tier-2, re-baking the rotated
  token into the remote URL each cycle.

## 0.47.0

### Minor Changes

- [#216](https://github.com/skaile-ai/workspaces/pull/216) [`890dc86`](https://github.com/skaile-ai/workspaces/commit/890dc865461e46510fea4a1a7f06d44ce5cdf777) Thanks [@jiradaherbst](https://github.com/jiradaherbst)! - git connector: the `sync_status` tool op now reports `hasUpstream`.

  `hasUpstream` is `false` when the current branch has no configured upstream — i.e. it has never been pushed. This distinguishes a never-pushed branch from a genuinely in-sync one.

  Both states otherwise report `ahead: 0, behind: 0` (there is no remote-tracking ref to diff against), so a consumer could not previously tell them apart and a never-pushed branch read as "synced". The field is additive; the host-side reader already computes the equivalent flag.

### Patch Changes

- [#220](https://github.com/skaile-ai/workspaces/pull/220) [`1e07379`](https://github.com/skaile-ai/workspaces/commit/1e07379530679132084de8bc2f3ddc2fa96b0a64) Thanks [@peteralbert](https://github.com/peteralbert)! - git connector: refresh `origin`'s auth on every `connect`, so a woken session always uses a live token.

  The driver now reconciles `origin`'s auth on every connect, including the existing-checkout wake path that previously left a stale baked token in `.git/config`. Two bugs are fixed:

  - **Existing-checkout wake never rewrote the remote**, so a woken session kept the dead connect-time token across wakes.
  - **Tier-2 (managed credential helper present) was shadowed by the baked inline URL token** — git ignores a credential helper when the remote URL carries inline credentials.

  The fix: for Tier-2 (`state.managedGitconfig` set), `stripRemoteInlineToken()` strips inline `x-access-token:TOKEN@` creds from `origin` so git consults the refreshing helper; for Tier-1 (no helper), `rebakeRemoteAuth()` rewrites `origin` with the freshly-minted token.

- [#219](https://github.com/skaile-ai/workspaces/pull/219) [`80bf524`](https://github.com/skaile-ai/workspaces/commit/80bf524ad981e6e4079f616b19c5a2c6eb2764e4) Thanks [@peteralbert](https://github.com/peteralbert)! - connectors: force single-part PUT on SharePoint mounts to fix the chronic upload-session 404 on file updates.

  `_buildRcloneArgs` now appends `--onedrive-upload-cutoff 4Mi` for the `sharepoint` driver. SharePoint mutates uploaded bytes server-side, which invalidates rclone's resumable upload session mid-`PUT` on an **in-place update** of an existing file — the chunk 404s with `itemNotFound: The upload session was not found` and the write-back retries forever (creates succeed; only updates fail), eventually crashing the fleet mount. A single-part PUT (used below the cutoff) has no session to lose, so updates now stick.

  `4Mi` is the Microsoft Graph single-part ceiling (rclone rejects larger). Files ≥ 4 MiB still require a resumable session and remain exposed to this bug on update — a documented gap (see `connectors/CLAUDE.md`).

## 0.46.0

### Minor Changes

- [#213](https://github.com/skaile-ai/workspaces/pull/213) [`1320ccc`](https://github.com/skaile-ai/workspaces/commit/1320cccfc2a3c2705c8c86f73e4a9d7f9fca426c) Thanks [@mortegro](https://github.com/mortegro)! - Add Agent Skills (agentskills.io) standard conformance linting for `SKILL.md`
  skills — a non-breaking authoring/CI check that keeps skaile skills portable to
  the open-standard ecosystem.

  - New pure `checkAgentSkillConformance()` export from `@skaile/workspaces/core`:
    flags `name` > 64 chars, `name` ≠ parent directory, `description` > 1024,
    `compatibility` > 500, non-string `metadata.*` values (skaile's nested
    metadata is _reported_, never rewritten), and mistyped `allowed-tools`.
  - `SkillManifestSchema` now types the standard's optional `allowed-tools` field.
  - `skaile validate [path]` runs the check for skill manifests as **advisory
    warnings by default** (exit code unchanged); the new `--strict-conformance`
    flag escalates findings to errors. `skaile asset import` surfaces the same
    findings.

  No schema migration and no breaking change to existing skills.

- [#215](https://github.com/skaile-ai/workspaces/pull/215) [`cabf67a`](https://github.com/skaile-ai/workspaces/commit/cabf67a5391159134c6b028871f8c4b94f35b215) Thanks [@Frozen666](https://github.com/Frozen666)! - Add a read-only `sync_status` tool operation to the git connector so callers can
  query a mount's git sync state from inside a live agent container — current
  branch, dirty flag, ahead/behind vs the upstream, and divergence vs the base
  branch. This backs the platform's git sync-status badge for in-container sources
  (Git / SharePoint / Empty), whose host workspace dir is empty even while the
  container is awake.

  - New `readGitSyncStatus(cwd, baseBranch)` helper in the git driver: pure and
    never-throwing (every git call degrades to a safe default), performs no network
    fetch (counts reflect locally-known remote-tracking refs), and validates
    `baseBranch` before interpolation (falls back to `origin/main`).
  - The git connector now exposes a `tools` face with one `sync_status` op
    returning the JSON snapshot `{ branch, dirty, ahead, behind, mainAhead,
mainBehind, commitSha }`.

  No breaking change; existing git mount behaviour is unchanged.

### Patch Changes

- [#211](https://github.com/skaile-ai/workspaces/pull/211) [`069f87d`](https://github.com/skaile-ai/workspaces/commit/069f87d8124f978d842082588a8888deff62fe53) Thanks [@mortegro](https://github.com/mortegro)! - Fix the cross-org clone-cache key collision the cache consolidation left in
  place, normalize ref-grammar docs, and harden two install/source footguns.

  **Cache key collision (bug fix).** The source clone cache keyed each repo by the
  _last URL path segment only_ (`sourceSlug`/`deriveSlug`), so
  `github.com/orgA/ai-assets` and `github.com/orgB/ai-assets` both mapped to one
  cache dir and the wrong repo could be served silently. The 2026-06-17 cache
  consolidation unified the cache _root_ (`~/.skaile/cache/sources/`) but kept this
  collision-prone key. This change introduces one canonical `sourceCacheKey(url)`
  (exported from `@skaile/workspaces/core`) keying the cache by
  `<host>/<owner>/<repo>`, replaces the duplicated last-segment slug functions
  across `core`, `asset-manager`, and `cli`, and adds `git remote` verification on
  cache reuse (a stale flat-slug dir whose origin maps to a different key is wiped
  and re-cloned). That remote check applies **only to dirs that are their own git
  clone** — a local `path:` source that is not a standalone clone (e.g. the in-repo
  factory-assets tree) is never remote-verified or wiped. `getGlobalCacheDir()` /
  `globalCacheRoot()` / `SKAILE_CACHE_DIR` are unchanged — only the per-repo key
  under that root changes. Human-facing labels and source library names keep the
  short `<repo>` form via `sourceShortName(url)`.

  **Ref-grammar docs.** Normalized the remaining help text and docs that still
  taught the rejected trailing-`@version` / `kind:name[@repo]` forms to the
  canonical `kind:@<publisher>/name#<version>` (`catalog.ts` add help, `cli/README.md`,
  `cli/CLAUDE.md`, `docs/cli/commands/{asset,project}.md`, `docs/core/api-reference.md`).

  **Footguns.** `skaile install` now exits non-zero on a partial miss (it printed
  `Missing:` lines but exited 0, hiding half-resolved installs from CI). `skaile
source add` now rejects a URL that maps to the same cache key as an
  already-registered source under a different URL string (e.g. the `.git`/scp form
  of the same remote), instead of silently creating two `skaile.yaml` declarations
  fighting over one clone dir.

## 0.45.0

### Minor Changes

- [#207](https://github.com/skaile-ai/workspaces/pull/207) [`14cc088`](https://github.com/skaile-ai/workspaces/commit/14cc088c01563de855b9fb4bd5eb63a14aec4600) Thanks [@mortegro](https://github.com/mortegro)! - CLI: `skaile list` and `skaile asset list` now show the **declared** dependencies
  from `skaile.yaml` (top-level intent), each annotated with a reconciliation state
  — `declared` (not yet resolved), `installed` (locked + materialized on disk),
  `drift` (locked but files missing), or `invalid` (entry isn't a parseable
  canonical ref). Transitive lock-only deps are excluded. The "what's available to
  install" view now lives exclusively in `skaile search`.

  - `skaile list [kind] [--json] [--global] [--target <agent>]` reads `skaile.yaml`
    `dependencies:` instead of `.skaile/deployed.yaml` and renders a `STATE` column;
    `--json` emits `{ ref, kind, name, state }`.
  - `skaile asset list` is now a synonym for `skaile list` (its old cross-library
    `AssetDefinition` browse — `--library`/`--domain`/`--kind` — is gone; use
    `skaile search`).
  - No `skaile.yaml` → actionable error pointing at `skaile init`; legacy
    `repositories:`/`ai_resources:` keys → parse error pointing at the
    `migrate-skaile-manifest` skill.
  - New public API on `@skaile/workspaces/asset-manager`: `AssetManager.listDeclared`,
    `DeclaredAsset`, `DeclaredState`, `MissingManifestError`. `SKAILE_YAML_DEFAULT` is
    now re-exported from `@skaile/workspaces/core`.

- [#200](https://github.com/skaile-ai/workspaces/pull/200) [`22ed138`](https://github.com/skaile-ai/workspaces/commit/22ed13839c0e8430f675f6ea48c486267e63a5b4) Thanks [@mortegro](https://github.com/mortegro)! - feat(cli): skailify — import existing coding-agent skills into a library

  Add a clean migration path from a Claude Code skill (`~/.claude/skills/`) to a
  skaile library:

  - **`skaile asset import <name-or-path>`** — resolves a bare skill name from
    `~/.claude/skills/` (or any `SKILL.md` directory by path), normalizes its
    frontmatter to the skaile schema (injects the `metadata:` block with
    `version`/`stage`/`tags`, slugifies a non-canonical name), scaffolds the
    target `--domain` on demand, copies companion files, and auto-wires the
    `skaile.manifest.yaml` entry. The low-level byte mover `asset migrate` is
    unchanged.
  - **`skaile library domain create|list|show`** — first-class domain management.
    `create` scaffolds `<lib>/<domain>/DOMAIN.md` with a publisher slug and marks
    the library domain-structured; a single library is resolved automatically when
    no default is set.
  - New first-party **`skailify`** factory skill that drives the above flow
    conversationally.

### Patch Changes

- [#210](https://github.com/skaile-ai/workspaces/pull/210) [`f243d11`](https://github.com/skaile-ai/workspaces/commit/f243d1131d1de7c815aaab3b75f189b12d2ad342) Thanks [@peteralbert](https://github.com/peteralbert)! - Coalesce `file_changed` event bursts from bulk git operations. A triage agent
  running `git checkout` / `pull --rebase` / `merge` / `stash pop` on a large repo
  rewrites hundreds-to-thousands of distinct working-tree files; chokidar emitted
  one `file_changed` per file (~170k/day). The existing per-path dedup did not
  help because a bulk checkout touches many _distinct_ paths inside the window.

  The runner's watch path now feeds events through a per-mount `FileChangedCoalescer`:
  once more than 50 events arrive within ~1s for a mount, the remainder of the
  burst collapses BY DIRECTORY — one representative `file_changed` with a REAL
  path per distinct affected directory, emitted on quiescence. A directory that
  saw a structural change (create/delete) gets a structural representative so the
  frontend refetches its grandparent too. Sub-threshold traffic (interactive
  saves) streams through unchanged.

  Per-directory real paths (not one empty-path summary) are required because the
  platform consumer is directory-granular: the backend SSE filter only forwards a
  `file_changed` to a user whose open files / expanded folders match the path,
  and the frontend refetches the PARENT directory of the changed path. An
  empty-path summary bypasses the filter and refreshes only the mount root,
  leaving deep expanded folders stale after a bulk checkout — the regression this
  rework fixes. Representatives are capped (256/burst); past the cap the capped
  set is emitted plus a single root-level fallback (logged) so nothing is silently
  dropped. Still a plain `file_changed`, no new event type. Refs
  skaile-ai/platform#972 / [#968](https://github.com/skaile-ai/workspaces/issues/968).

## 0.44.0

### Minor Changes

- [#191](https://github.com/skaile-ai/workspaces/pull/191) [`dc2587d`](https://github.com/skaile-ai/workspaces/commit/dc2587d160183b6aacedacdfad31721356e89c92) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Consolidate re-derivable clones/bytes under a clearly-named `cache/` root at each
  scope and add `skaile cache clean/dir/size`. Source clones unify under
  `~/.skaile/cache/sources` (was split across `~/.skaile/repos` and
  `~/.skaile/sources`); the catalog cache moves to `~/.skaile/cache/catalog`;
  project staging moves to `.skaile/cache/{store,sources}`. Sidecars are renamed to
  `~/.skaile/sidecars` (authored content — never wiped by `cache clean`). Existing
  data is auto-migrated on first run. `SKAILE_CACHE_DIR` now sets the cache root
  (not the repos dir) — see MIGRATION.md.

- [#204](https://github.com/skaile-ai/workspaces/pull/204) [`7ed6674`](https://github.com/skaile-ai/workspaces/commit/7ed66741485cb619c6d30ee2ff9b06fde530948a) Thanks [@peteralbert](https://github.com/peteralbert)! - Add an extensible `custom` mention kind to the `Mention` type plus an `isAddressedByKind(mentions, kind)` helper, and teach `isMentioned` to honor custom mentions' `resolved` ids. Backward compatible (additive union member); `parseMentions`/`isAgentAddressed` unchanged.

- [#191](https://github.com/skaile-ai/workspaces/pull/191) [`5efb2ae`](https://github.com/skaile-ai/workspaces/commit/5efb2aeb62c2405bcac1e5c911b043fb7b150ba2) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Fix: store-published assets can now be deployed into a project. `skaile add <store-ref>` (and non-locked `skaile install`) fetch a store-resolved asset's bytes from its upstream source at the pinned commit, verify per-file SHA256, stage them under `.skaile/store-cache/`, and deploy the verified bytes into the driver target (`.claude/skills/…`) — reusing the same fetch+verify path as the pointer-only `library.install`. Previously a store asset either reported "Asset not found" (no project `stores:` registered) or crashed the deploy with `ENOENT … /factory-assets/…` because the deploy path tried to read bytes from a local clone that never held them.

  `add`/`install` now fall back to the global `~/.skaile/config.yaml` `catalog.url` when the project declares no `stores:` (best-effort; offline never regresses missing-dependency reporting), so no manual `skaile.yaml` edit is needed. `mcp-server` and other non-deployable kinds are unchanged. `AssetManagerOptions` gains optional `storeFetcher` / `assetFetcher` / `catalogUrl` seams for network-free testing (additive, non-breaking).

### Patch Changes

- [#203](https://github.com/skaile-ai/workspaces/pull/203) [`841470f`](https://github.com/skaile-ai/workspaces/commit/841470f6bd1384132c6966980dff6633b51e30ea) Thanks [@Frozen666](https://github.com/Frozen666)! - Auto-recover a poisoned rclone VFS cache. When a remote filesystem mount
  (SharePoint/WebDAV/OneDrive) fails to attach because a prior container was
  SIGKILLed mid-write and left the persistent `--vfs-cache-mode full` cache
  inconsistent (`failed to reload item` / `failed to add virtual dir entry`),
  `RcloneProcessManager.spawn` now quarantines the `vfs`/`vfsMeta` cache
  move-aside into `<cacheDir>/.corrupt-<ISO>` and retries the mount exactly once
  on a clean rebuild from the authoritative remote. The retry is single-shot and
  bounded — a second failure propagates without another reset, so there is no
  reset/respawn loop — and the cache is never hard-deleted.

## 0.43.1

### Patch Changes

- [#197](https://github.com/skaile-ai/workspaces/pull/197) [`e7af1fb`](https://github.com/skaile-ai/workspaces/commit/e7af1fb5453ee8cfed160b64b9f3a0b51d214405) Thanks [@peteralbert](https://github.com/peteralbert)! - Lift in-container skaile-not-ready placeholder edge-triggered when the host fleet mount recovers, without a container restart.

## 0.43.0

### Minor Changes

- [#195](https://github.com/skaile-ai/workspaces/pull/195) [`afc17a0`](https://github.com/skaile-ai/workspaces/commit/afc17a0d993cffecdfbd1898feb0d66dc3dbef1d) Thanks [@peteralbert](https://github.com/peteralbert)! - MCP manifest: optional `payload` field (https-only url, 64-hex sha256, bare-filename dest) for release-asset delivery, plus a `${workspace}` substitution token applied to every MCP declaration's command/args/env so command-shape servers can reference the materialized asset dir.

## 0.42.1

### Patch Changes

- [#193](https://github.com/skaile-ai/workspaces/pull/193) [`baba456`](https://github.com/skaile-ai/workspaces/commit/baba4560efb4e710a89600552643cf06b1c4dac4) Thanks [@peteralbert](https://github.com/peteralbert)! - ConnectorManager now rejects two filesystem-face connectors that resolve to the
  **same** absolute mount target. Previously both bound silently — one shadowing
  the other, order-dependent — making the misconfig impossible to diagnose from the
  runtime. Setup now throws a clear error naming both connector ids and the shared
  path; a nested-target overlap (e.g. `/skaile` vs `/skaile/workspace`) is logged
  as a warning. Distinct targets, including the per-id default `.mounts/<id>`, are
  unaffected.

## 0.42.0

### Minor Changes

- [#187](https://github.com/skaile-ai/workspaces/pull/187) [`67b88c7`](https://github.com/skaile-ai/workspaces/commit/67b88c75be6ef0aae6a20f28e62de412c7a998d1) Thanks [@peteralbert](https://github.com/peteralbert)! - Runner reads CONNECTOR.md/MCP.md body and injects preset custom instructions into the agent system prompt. `ConnectorDeclaration` and `McpServerDeclaration` gain an optional `instructions` field populated from the materialized markdown body. Connector instructions are emitted eagerly in the `## Connectors` system-prompt section; MCP server instructions in a new `## MCP server guidance` section. When the body is empty (today's platform output) no block is added — safe to ship ahead of the platform side.

- [#186](https://github.com/skaile-ai/workspaces/pull/186) [`83c7f9c`](https://github.com/skaile-ai/workspaces/commit/83c7f9c4651aea27df0db220f4e9ff9ce80be89a) Thanks [@peteralbert](https://github.com/peteralbert)! - Fix the rclone VFS stale-tail read bug at the source ([#183](https://github.com/skaile-ai/workspaces/issues/183)). A writable
  rclone-backed mount (SharePoint / OneDrive / WebDAV / Google Drive) can serve a
  **non-truncated** VFS cache item after a remote file is replaced by a _shorter_
  version: the read returns `[new bytes][leftover tail of the older, longer
version]`, and `statSync` reports the inflated size, so the on-disk length is no
  signal. This used to be masked by the `text/html` `</html>`-truncation heuristic
  removed in 0.41.6.

  The read path now gates the served bytes against the **authoritative remote
  size** instead of any content heuristic:

  - `RcloneProcessManager.statSize(handle, relPath)` queries the `operations/stat`
    rc endpoint, which resolves the object against the backend directly and so
    returns the true remote size, bypassing the VFS cache. New exported helpers
    `fetchAuthoritativeSize` (and the internal `_operationsStatTarget`) back it.
  - The `sharepoint` / `webdav` / `googledrive` connectors attach an
    `authoritativeSize(relPath)` hook (new `AuthoritativeSizeProvider` type) to
    their `ConnectorHandle.state`, wired to `statSize`.
  - `handleMountResourceRequest` (now async) duck-types that hook and truncates
    the read to the authoritative size — slicing the Buffer _before_ any utf-8
    decode, so multibyte content is never split mid-character. Absent hook (local
    mounts) or `null` (object missing / rc call failed) ⇒ on-disk bytes served
    unchanged.

  Other candidate fixes were rejected: `vfs/forget` + re-read (re-downloads on
  every changed read), tuning `--vfs-cache-mode`/`--dir-cache-time` (per-read
  latency, doesn't address the non-truncation), and write-path `O_TRUNC` (our
  write path already truncates via FUSE; the bug is on remote-side rewrites).
  Fleet-managed mounts have no in-container rc server, so the host fleet remains
  responsible for their invalidation — a documented follow-up.

  All additions are new exports; no public surface was removed.

### Patch Changes

- [#189](https://github.com/skaile-ai/workspaces/pull/189) [`81e2da1`](https://github.com/skaile-ai/workspaces/commit/81e2da1a8a1cce5ebca4252afa4e7e2366e1da64) Thanks [@peteralbert](https://github.com/peteralbert)! - Fleet read-only mount barrier now tracks the actual mount state across three surfaces instead of two. A `healthy` fleet-health verdict lifts the connect-time not-ready barrier (previously a late-arriving live bind that propagated under the barrier stayed masked even after it went healthy), and a connector that only had the static boot-time barrier is promoted to a health gate when a fleet verdict arrives, so the authoritative platform signal can lift it. A new `degraded` verdict (live and readable but writes failing) downgrades the barrier to a read-only-readable surface — the live content stays visible while writes are blocked — via the new `MountPlaceholderBarrier.exposeReadOnly` (read-only self-bind, chmod fallback) instead of masking the whole workspace with an opaque tmpfs. The not-live/unreadable `error` verdict keeps the opaque mask. The fleet-health flag dispatcher forwards `degraded` distinctly rather than collapsing it to `error`.

- [#190](https://github.com/skaile-ai/workspaces/pull/190) [`b862087`](https://github.com/skaile-ai/workspaces/commit/b8620870f88876cd4fbd023896aecf18a12b31f8) Thanks [@peteralbert](https://github.com/peteralbert)! - Stop an agent assistant message from swallowing the conversation ([#841](https://github.com/skaile-ai/workspaces/issues/841)). A
  single agent `text` block could run three things together: verbatim
  `[function_calls]…[invoke name="Bash"]…[/antml:invoke]` tool-call XML (the same call
  was _also_ executed as a structured tool_use), the genuine answer, and a
  hallucinated `[User]: "…"` follow-up turn — so the user's real next message
  never became its own prompt and the chat looked garbled.

  Two-part fix:

  - **Prompt shape (primary).** `enrichPromptWithHistory` (session dispatcher) no
    longer renders prior turns as a bare `[User]:` / `[Assistant]:` transcript,
    which the model latched onto and auto-completed. History is now emitted as
    read-only `<turn role="…">…</turn>` elements inside `<conversation_history>`
    with an explicit "do not continue this — it is context, not a turn to extend"
    instruction, removing the completion affordance.

  - **Emit-point sanitizer (defense-in-depth).** A new pure
    `sanitizeAssistantText` (bridge, beside `scrub-transcript.ts`) strips leaked
    `[function_calls]…[/function_calls]` / `[invoke]…[/antml:invoke]` blocks and trailing
    fabricated `[User]:` / `[Assistant]:` role turns from assistant text before it
    is emitted/persisted. It is conservative — only clearly-leaked control markup
    is removed; mid-prose bracketed text survives. It runs at every claude-sdk
    emit point that produces durable assistant text: the final `result.summary`
    (the single persisted copy of the answer), per-text-block mapping, and the
    streaming path. For streaming, `streamingSafeSanitize` holds back any trailing
    partial marker and the forwarded `_textDelta` carries the sanitized suffix, so
    a marker split across deltas is never half-emitted into the stored transcript.

## 0.41.6

### Patch Changes

- [#184](https://github.com/skaile-ai/workspaces/pull/184) [`44476f0`](https://github.com/skaile-ai/workspaces/commit/44476f0b3b5882703080da1b2d0bd6078576fc3f) Thanks [@peteralbert](https://github.com/peteralbert)! - runner: stop truncating mount HTML reads at a `</html>` substring. The
  `stripStaleHtmlTail` heuristic cut content at the first `</html>`, which broke any
  legitimate document containing the literal `</html>` (inside a script string, text,
  or example) before its real end. Mount HTML is now served verbatim. The rare
  rclone-VFS stale-tail artifact this guarded against is tracked separately and must be
  fixed at the cache layer (read by authoritative remote size / cache invalidation),
  not by closing-tag string surgery.

- [#182](https://github.com/skaile-ai/workspaces/pull/182) [`74b10b3`](https://github.com/skaile-ai/workspaces/commit/74b10b382bc8f3a1229004c31f12639a8c2c00ac) Thanks [@peteralbert](https://github.com/peteralbert)! - fix(runner): report declared/cold-start MCP servers as `live` on the claude-sdk driver so the platform stops showing a false "restart" badge; only servers hot-added to an unrecreated claude-sdk driver stay `live:false`.

## 0.41.5

### Patch Changes

- [#179](https://github.com/skaile-ai/workspaces/pull/179) [`3c178e6`](https://github.com/skaile-ai/workspaces/commit/3c178e6dbdeb4de59d18dce6f43e8aee00178908) Thanks [@peteralbert](https://github.com/peteralbert)! - chore(sharepoint): remove the in-container `resolveWebUrl` custom op

  SharePoint "Open in SharePoint" web-URL resolution moved host-side in the platform (the in-container connector has no OAuth token for fleet-managed mounts). The runner's `resolveWebUrl`/`_customOp` path is therefore dead code: removes `SharePointConnector.resolveWebUrl` + `buildGraphWebUrlEndpoint`, the `_customOp` branch in the mount resource handler (reverting it to its synchronous form), and their tests. No remaining caller.

- [#181](https://github.com/skaile-ai/workspaces/pull/181) [`3657030`](https://github.com/skaile-ai/workspaces/commit/36570300b6169fae336e2dec8bd633926323da77) Thanks [@peteralbert](https://github.com/peteralbert)! - rclone fleet: stop classifying `itemNotFound` upload errors as permanently non-retryable. itemNotFound is a SharePoint/OneDrive Graph 404 on the upload target that is frequently transient (stale VFS dir-cache parent-id after a folder is recreated remotely, or new-folder indexing lag), and clears once rclone re-lists the parent. Firing the upload-stuck signal on the first occurrence tripped a premature recycle/quarantine and stranded recoverable writes. itemNotFound now rides the same per-object stuck threshold as ordinary upload failures (counts consecutive occurrences, resets on an upload success), so rclone's own retries can clear a transient 404 while a genuinely-vanished target still escalates.

## 0.41.4

### Patch Changes

- [#177](https://github.com/skaile-ai/workspaces/pull/177) [`2aff69b`](https://github.com/skaile-ai/workspaces/commit/2aff69b1e155795da3a28ed4a16197eb33125cd5) Thanks [@peteralbert](https://github.com/peteralbert)! - fix(sharepoint): implement `resolveWebUrl` so "Open in SharePoint" works for Office files

  The SharePoint connector now answers the `resolveWebUrl` custom op (Microsoft Graph `webUrl` lookup) that the platform's `resolveViewerUrl` path already calls. Previously no connector implemented it and the mount resource handler had no `_customOp` branch, so Office files (xlsx/docx/pptx) on a SharePoint mount only offered a download instead of opening in SharePoint. Fleet-managed mounts return null (no in-container token) and keep the download fallback.

## 0.41.3

### Patch Changes

- [#175](https://github.com/skaile-ai/workspaces/pull/175) [`0189a1e`](https://github.com/skaile-ai/workspaces/commit/0189a1ed6a4615a9664140e88715f406cdfd6a94) Thanks [@peteralbert](https://github.com/peteralbert)! - Discovery now reads an asset version from `metadata.version` when the top-level `version:` is absent. Claude-skill `SKILL.md` frontmatter conventionally nests custom fields under `metadata:`, so a version declared there was previously dropped and silently defaulted to `0.1.0`. The fallback applies to both discovery paths — local (`discoverAssetsInTree`) and the virtual-tree path the store sync uses (`discoverAssetsInTreeEntries`). Top-level `version:` still wins when both are present.

## 0.41.2

### Patch Changes

- [#171](https://github.com/skaile-ai/workspaces/pull/171) [`e403d2f`](https://github.com/skaile-ai/workspaces/commit/e403d2f6c55aeb38df430a0efc3efae3759a38b0) Thanks [@mortegro](https://github.com/mortegro)! - Register all 19 factory connectors in `factory-assets/skaile.manifest.yaml`.
  The manifest declared skills + a bundle but no connectors; because it is
  non-empty, the walker runs manifest-driven and skips the filename-convention
  fallback that indexes `CONNECTOR.md` files, so `AssetManager.add`'s provenance
  index contained zero factory connectors. The manage TUI lists them via a
  separate `scanRepo` path, producing a select-but-fail-to-add
  (`Asset not found: connector:@skaile-ai/deploy`).

  **Breaking:** also renames the static file-server connector's identity
  `static` → `static-server` (registry key, catalog id, `CONNECTOR.md` `name:`,
  adapter `name`, and `driver:`) so the catalog key matches its folder. Any
  `skaile.yaml` using `driver: static` must switch to `driver: static-server`
  (see MIGRATION.md).

- [#174](https://github.com/skaile-ai/workspaces/pull/174) [`2ec5b68`](https://github.com/skaile-ai/workspaces/commit/2ec5b68d9c4f9941d1a39877684e20fb5b34cd1a) Thanks [@peteralbert](https://github.com/peteralbert)! - Fix remote MCP servers declared with `transport: http` (or the MCP-spec alias `transport: streamable-http`) in an `MCP.md` catalog manifest.

  - The `McpServerManifestSchema` enum now accepts `http` alongside `streamable-http` (it previously allowed only `streamable-http`, which the runner does not recognize).
  - `mcpDeclFromCatalogEntry` normalizes `streamable-http` -> `http`, the literal `external-mcp.ts` branches on to open a `StreamableHTTPClientTransport`.

  Without this, a catalog-published remote MCP either failed manifest validation (`http`) or produced a declaration the runner could not connect (`streamable-http`).

## 0.41.1

### Patch Changes

- [#166](https://github.com/skaile-ai/workspaces/pull/166) [`3fbd7a2`](https://github.com/skaile-ai/workspaces/commit/3fbd7a26931797cbbaff407019c413dcd28de3d4) Thanks [@mortegro](https://github.com/mortegro)! - Fix `WorkspaceYamlEditor` list getters reading a loaded file. `getSources`,
  `getStores`, `getOverrides`, and `getConnectors` tested `Array.isArray` against
  `doc.get(key)`, which returns the `YAMLSeq` node (never a JS array) for a YAML
  collection — so every getter silently returned `[]`. `skaile source sync`
  reported "No sources in skaile.yaml" for a file that clearly had them. The
  getters now read the node and `toJSON()` it.

  Also: `skaile source sync` with no argument now prompts which sources to sync
  (multiselect) when run interactively; `--all` syncs every source without
  prompting, and a single source or non-TTY shell syncs all as before.

- [#170](https://github.com/skaile-ai/workspaces/pull/170) [`547118c`](https://github.com/skaile-ai/workspaces/commit/547118c91a5b79984dc92897c862d37719a8c138) Thanks [@peteralbert](https://github.com/peteralbert)! - Runner: support live-attaching a SKILL asset enabled mid-session. The
  `runner.attach_instance` capability previously handled `mcp-server` and
  `connector` kinds only — a `skill` fell through to `unsupported_kind`, so a
  skill enabled while the agent container was running never reached the driver
  (it only appeared on the next cold wake). The new `skill` branch stages the
  platform-materialized skill from `.skaile/assets/skill/<name>/` into
  `.claude/skills/` (the same path cold boot uses, via `stageMaterializedSkills`)
  and fast-restarts the driver so the SDK rediscovers it and emits
  `skills_changed`. `restartDriverForSkillChanges` now accepts the minimal
  `{ name, action }` shape shared by the `session_init` aiResources path and this
  live skill path.

## 0.41.0

### Minor Changes

- [#167](https://github.com/skaile-ai/workspaces/pull/167) [`971e789`](https://github.com/skaile-ai/workspaces/commit/971e78952bab2e3bc6430bccad799c5feca9fbdd) Thanks [@peteralbert](https://github.com/peteralbert)! - Add `@skaile/workspaces/core/ref` — the single source of truth for the catalog **wire** ref grammar `[kind:]publisher/name#version` (`@` = scope sigil, `#` = the only version delimiter; spec `2026-06-02-scoped-asset-ref-grammar`).

  - `parseCatalogRef(ref)` — lenient parse (optional `kind:`, optional leading `@`) to `{ kind, publisher, name, version }`; returns `null` on malformed input and on a legacy `@version` ref (`@` is scope-only).
  - `toCatalogWireRef(ref)` — normalize a loose ref (incl. an internal `@version` form) to the wire string: strip a leading `@`, rewrite a post-name `@version` to `#version`; idempotent.
  - Re-exports the strict graph/lock parser (`parseAssetRef` / `assetRefToStr` / `isValidAssetName` / `ASSET_NAME_HINT`) so a consumer needs one import for either need.

  The module is pure (only `semver`, no native deps), so DB-free consumers — the public store's catalog routes and the platform's store client — can value-import it and retire their duplicated local parsers (`parseStoreRef`, `toStoreRef`).

- [#165](https://github.com/skaile-ai/workspaces/pull/165) [`c639bc5`](https://github.com/skaile-ai/workspaces/commit/c639bc5625f075b0d801c0ca54eb9b7fadb5fad7) Thanks [@mortegro](https://github.com/mortegro)! - Tolerate non-canonical asset names from third-party sources we don't control.
  The ingestion boundary (`scanDirectory`, the provenance walker) now slugifies a
  human-styled identity (`"Complex App"` → `"complex-app"`) into a canonical
  kebab-case name instead of crashing on `parseAssetRef`. The original is preserved
  on `CatalogEntry.metadata.displayName` for display; identity uses the slug.

  Adds a `slugifyAssetName(raw)` export to `@skaile/workspaces/core` (the inverse
  companion to `isValidAssetName`). Authoring-time validation (`skaile validate`)
  still rejects non-canonical names on raw frontmatter, so first-party typos surface
  there rather than being silently rewritten.

## 0.40.2

### Patch Changes

- [#163](https://github.com/skaile-ai/workspaces/pull/163) [`7d9bcdf`](https://github.com/skaile-ai/workspaces/commit/7d9bcdfefce3293c7653a3ad399afa8ce5b099f9) Thanks [@peteralbert](https://github.com/peteralbert)! - runner: attach_instance stages a pre-minted bearer under mcp:<id> so a live-attached remote MCP authenticates without a session restart (Phase 3b). Additive and best-effort; a runner without it ignores the field harmlessly.

## 0.40.1

### Patch Changes

- [#161](https://github.com/skaile-ai/workspaces/pull/161) [`c6ae536`](https://github.com/skaile-ai/workspaces/commit/c6ae53686a48f91618df7fab958f032a844aac14) Thanks [@peteralbert](https://github.com/peteralbert)! - Fix in-container rclone mounts failing over the read-only deferred-mount barrier.

  A non-fleet deferred remote mount (sharepoint/webdav/googledrive) brings its FUSE
  mount up via in-container rclone. The static read-only tmpfs barrier left the
  mountpoint read-only, so `fusermount3` rejected the mount with "user has no write
  access to mountpoint" — the mount never came up and the agent saw no data (prod
  incident 2026-06-12). `ConnectorManager` now lifts the barrier to read-write
  (`prepareForStackedMount`) immediately before the mount stacks on top, and
  re-asserts read-only (`restoreBarrier`) if the mount fails. The lift is mount-mode
  only (the shadowing tmpfs keeps the real dir hidden); the no-CAP_SYS_ADMIN chmod
  fallback is not pre-lifted, preserving the never-ready read-only guarantee.

## 0.40.0

### Minor Changes

- [#156](https://github.com/skaile-ai/workspaces/pull/156) [`b193edb`](https://github.com/skaile-ai/workspaces/commit/b193edb6ec2d60e42ed2b89e0f0b677d883d244e) Thanks [@mortegro](https://github.com/mortegro)! - Catalog sources can now surface navigational domains. `ICatalogSource` gains an
  optional `listDomains(filter?)` method plus the `CatalogDomain` /
  `CatalogDomainFilter` / `DomainRelation` types (`@skaile/workspaces/plugins`).
  `RemoteCatalogSource` implements it against the store's `catalog.listDomains`
  tRPC endpoint, mapping the wire rows to `CatalogDomain` and degrading to `[]` on
  a missing endpoint, transport error, or air-gapped mode so it never jeopardizes
  the asset feed it accompanies. Sources without domains (local libraries, the
  REST framing, older stores) simply omit the method — consumers treat absence as
  "no domains". This is the client half that lights up the domain grouping in
  `skaile manage` once the store serves domains.

- [#140](https://github.com/skaile-ai/workspaces/pull/140) [`5693d62`](https://github.com/skaile-ai/workspaces/commit/5693d621e2cc139a80aeb913448a70f8fdfb7477) Thanks [@mortegro](https://github.com/mortegro)! - feat(cli): surface bundled factory assets as a first-level band in `skaile manage`

  The manage TUI now gathers a fourth asset feed — the bundled first-party
  `factory-assets` tree (`factoryAssetRoots()`) — and renders it as its own
  top-level origin (`🏭 Skaile Factory Assets`) alongside Library, Sources, and
  Store, rather than folding it into Sources. Origin-filter shortcuts are
  renumbered to follow render order: `[1]lib [2]src [3]factory [4]store [0]all`.

### Patch Changes

- [#159](https://github.com/skaile-ai/workspaces/pull/159) [`5d6503b`](https://github.com/skaile-ai/workspaces/commit/5d6503b4849d30806a3e24d1eb82f5f5e016a84b) Thanks [@peteralbert](https://github.com/peteralbert)! - Harden the runner/bridge half of AI-credential mediation against the failure
  modes a shared Claude Max OAuth seat suffers (rotation storm, transient backend
  outage, single-attempt 401 mediation). Three changes:

  - **Transient-failure retry on the 401-mediation path.** The reactive
    `onAuthError` mint now bounded-retries a transient `backend-error` (e.g. the
    platform backend mid-redeploy) with backoff (~3 attempts over ~6s) before
    surfacing `AuthError`, riding out a short backend blip transparently. Terminal
    codes (`not-configured` / `revoked` / `provider-error`) and the
    `AccountUnavailableError` account-rejection case still surface immediately.
    Transient-vs-terminal is classified in one place
    (`runner/src/ai-credential-retry.ts`).

  - **Proactive refresh scheduler no longer dies on the first failure.** A failed
    proactive fire used to leave the scheduler idle forever. It now re-arms a
    bounded retry on a transient failure and, once the cap is reached, arms a long
    stale-schedule guard (re-check after 9h) so refresh can never be bricked for
    days; it re-arms (eagerly, even during the stale-guard window) on the
    configure/wake path via the new `ensureArmed()`; and scheduler/refresh
    failures now surface at ERROR level so they are visible in
    `skaile session logs` and the debug panel. The bounded retry is a retry of a
    _failed_ (non-rotating) refresh, so it never amplifies rotations on a shared
    seat.

  - **Forward-compat hooks for the platform single-flight + broadcast,** dormant
    until the platform PR ships and harmless against today's platform: an optional
    `rejectedFingerprint` (truncated SHA-256 of the just-rejected token) on the
    `retry-401` `host.refresh_credential` input, and an inbound `credentialRotated`
    broadcast handler that re-pulls the fresh token instead of each container
    force-refreshing.

  This is the `@skaile/workspaces` half of a two-PR change; a platform-side PR
  follows. Builds on the existing `AccountUnavailableError` classifier.

- [#157](https://github.com/skaile-ai/workspaces/pull/157) [`0951cc7`](https://github.com/skaile-ai/workspaces/commit/0951cc78f9da0c0137a8ca09b759691bfdc5fc53) Thanks [@peteralbert](https://github.com/peteralbert)! - fix(runner): make mount delete + list handle directories (folder rename)

  The platform renames a folder while the agent is running by copying it
  file-by-file then deleting the original (`renameViaLiveAgent`). Two bugs in the
  filesystem-mount resource handler broke this:

  - **delete** used `unlinkSync`, which throws `EISDIR` on a directory — the
    original folder was never removed, leaving the user with both the old and the
    renamed folder plus an error toast.
  - **list** ignored `options.recursive`, returning only top-level entries — so the
    copy silently skipped nested subfolders. The delete failure is the only reason
    the nested data survived; with delete fixed alone it would have been lost.

  `delete` now removes directories with `rmSync({ recursive: true })`; `list`
  descends into subfolders when `options.recursive` is set. Folder rename now
  copies the full tree and removes the source.

- [#158](https://github.com/skaile-ai/workspaces/pull/158) [`dcb73a5`](https://github.com/skaile-ai/workspaces/commit/dcb73a5f587fb95d04aff8bdfbc9442fc9596d33) Thanks [@peteralbert](https://github.com/peteralbert)! - fix(connectors): rclone now mounts over the deferred-mount read-only barrier

  Deferred remote mounts (sharepoint/webdav/googledrive) establish a read-only
  tmpfs barrier at the mount target before rclone starts, to close the
  phantom-workspace window. rclone refuses to FUSE-mount over an existing mount
  and aborted with `failed to mount FUSE fs: directory already mounted, use
--allow-non-empty to mount anyway`, leaving the agent with only the read-only
  barrier. `_buildRcloneArgs` now passes `--allow-non-empty` so rclone stacks its
  FUSE mount on top of the barrier as the design intended.

  Mount confirmation (`isMountpoint`) now requires a `fuse.*` fstype at the
  target. Previously it matched any mount, so the tmpfs barrier produced a false
  "mountpoint confirmed" and a dead rclone process was reported as connected.

- [#154](https://github.com/skaile-ai/workspaces/pull/154) [`81b4789`](https://github.com/skaile-ai/workspaces/commit/81b478942f2bb1d11b8894ca20ec3c350f11f23a) Thanks [@mortegro](https://github.com/mortegro)! - fix(cli): `skaile source sync/show/remove` now accept the same shortcut forms as `source add`

  These commands matched the argument only against the raw stored URL or its
  derived slug, so a shortcut form that `source add` accepted (a bare name,
  `owner/repo`, or a `.git`-suffixed spec) failed to resolve once the source was
  stored under its expanded canonical URL. A shared `sourceMatchesKey` helper now
  applies the same `normalizeSourceUrl` expansion before matching.

## 0.39.0

### Minor Changes

- [#152](https://github.com/skaile-ai/workspaces/pull/152) [`630af66`](https://github.com/skaile-ai/workspaces/commit/630af66a84918830e86ca643b9a2320e259bea33) Thanks [@peteralbert](https://github.com/peteralbert)! - Backend-auth materialized connectors now authenticate end-to-end, off-disk. The credential is delivered through the in-memory `session_init` channel — `cmd.credentials.connectors[<id>]` at boot/wake and a new optional `runner.attach_instance.preMintedToken` on live-attach — and staged into `PreMintedSecretProvider` under `connector:<id>`. Tool-face connectors now receive the wrapped token mediator (previously filesystem-face only), so a tool-face connector (e.g. postgres) resolves its `auth: backend` credential via `ctx.tokenMediator`. The connector secret is no longer read from `.instance.json` (`readResolvedConnectorSecret` removed) — credentials never touch disk.

### Patch Changes

- [#150](https://github.com/skaile-ai/workspaces/pull/150) [`e0cc721`](https://github.com/skaile-ai/workspaces/commit/e0cc7211c01dd96893d00c163ad799e2c744a098) Thanks [@Frozen666](https://github.com/Frozen666)! - fix(git-connector): publish session branch to origin on create (B-142)

  A non-main git session created its session branch locally but never pushed it,
  so peers and other sessions couldn't see the branch until a clean
  hibernate/close (and never with the default `pushAfterCommit=false`). Add a
  `lifecycle.publishOnCreate` flag (default `true`) that best-effort
  `push -u origin <branch>` on the branch-creation paths only (not reuse-local or
  checkout-from-origin), swallowing failures so `connect` never fails; the
  lifecycle push hooks remain the backstop.

- [#149](https://github.com/skaile-ai/workspaces/pull/149) [`3c604d0`](https://github.com/skaile-ai/workspaces/commit/3c604d0725299f257ad0753760bea14d2ca4c87d) Thanks [@Frozen666](https://github.com/Frozen666)! - fix(runner): strip stale tail after `</html>` in mount HTML reads

  A writable rclone mount can serve a non-truncated VFS cache item — a clean
  document followed by the leftover tail of an older, longer version of the same
  file (which carries its own duplicate closing tags). The mount read now truncates
  `text/html` content at the first `</html>`, so neither the workspace HTML preview
  nor the agent sees the garbled trailing fragment. Mitigation for the SharePoint
  preview corruption; the underlying rclone VFS cause is tracked separately.

## 0.38.0

### Minor Changes

- [#147](https://github.com/skaile-ai/workspaces/pull/147) [`9a77b0c`](https://github.com/skaile-ai/workspaces/commit/9a77b0c3f6c6d3ff24245e778efbf75e432b7976) Thanks [@peteralbert](https://github.com/peteralbert)! - feat(connectors): wire library-assigned connectors to the runtime (Phase 2.5).

  Materialized `.skaile/assets/connector/<name>/CONNECTOR.md` projections are now
  derived into ConnectorDeclarations at boot (`loadConnectorDeclarations` merges
  them alongside `skaile.yaml` connectors), and `runner.attach_instance` supports
  `kind: "connector"` (materialize-then-attach-by-ref → `ConnectorManager.connect`).
  Symmetric with the MCP live-attach path. Connectors stay first-party (driver
  resolved by name).

### Patch Changes

- [#146](https://github.com/skaile-ai/workspaces/pull/146) [`0b8edfd`](https://github.com/skaile-ai/workspaces/commit/0b8edfd530a27b7f08cea7899b384f97edbc4dde) Thanks [@peteralbert](https://github.com/peteralbert)! - rclone fleet hardening: spawn the rclone rc server with `--rc-no-auth` (loopback-only) so the flush-before-stop `POST /vfs/refresh` no longer 403s — graceful stop drains the write-back cache instead of leaving it poisoned. Surface non-retryable `itemNotFound` upload failures immediately via the existing stuck-upload signal (the remote target is gone; retrying can never land). Create `.skaile/fleet-health/` group-writable + setgid (`0o2770`) so the platform backend can write the verdict flag the runner reads.

## 0.37.0

### Minor Changes

- [#144](https://github.com/skaile-ai/workspaces/pull/144) [`91f371f`](https://github.com/skaile-ai/workspaces/commit/91f371fbd9fb8b112886500481e3011d7df045da) Thanks [@peteralbert](https://github.com/peteralbert)! - feat: `runner.attach_instance` capability — materialize-then-attach-by-ref for live MCP hot-plug. The platform materializes a just-assigned MCP instance to `.skaile/assets/mcp-server/<name>/` and invokes this cap; the runner self-derives the declaration from disk (`deriveSingleMaterializedMcpDeclaration`) and attaches it live via `ExternalMcpManager.addServer`. Connectors return `unsupported_kind` (deferred — Phase 2.5).

## 0.36.0

### Minor Changes

- [#143](https://github.com/skaile-ai/workspaces/pull/143) [`987613d`](https://github.com/skaile-ai/workspaces/commit/987613da9a77b3a4d8ceb3e47a0763cf2806af36) Thanks [@peteralbert](https://github.com/peteralbert)! - feat: live MCP hot-plug-on-assign — new `runner.add_mcp_server` capability (connect a remote/stdio MCP server to a running session and register its `mcp__<id>__*` tools with no restart) and MCP servers are now reported in the `resources_available` event (`mcp_servers: McpServerInfo[]`) so the platform can show a truthful Live/Restart badge.

### Patch Changes

- [#141](https://github.com/skaile-ai/workspaces/pull/141) [`ffc110e`](https://github.com/skaile-ai/workspaces/commit/ffc110eabb97e19d8f2f76f0354fdd5108da7083) Thanks [@Frozen666](https://github.com/Frozen666)! - fix(bridge): classify a 401 that recurs after a successful credential refresh as an `account` failure, not `auth`

  When Anthropic returns 401 for a non-auth reason (hidden-tier rate limit, billing block, anti-abuse throttle on OAuth tokens), the bridge refreshed the credential, retried, hit the same 401, and surfaced "Invalid authentication credentials" — a wrong, non-actionable message. The driver now downgrades a post-refresh 401 (`this.authSelfHealUsed === true`) from `auth` to a new `account` category and surfaces `AccountUnavailableError` (not an `AuthError`, so the self-heal does not loop). Applied on both the thrown-exception (`handleConsumerError`) and `is_error`-result (`handleResultErrorPayload`) paths. Adds `"account"` to `ErrorCategory` in `@skaile/workspaces/types`.

## 0.35.0

### Minor Changes

- [#122](https://github.com/skaile-ai/workspaces/pull/122) [`a20ed98`](https://github.com/skaile-ai/workspaces/commit/a20ed9841a4e10c82c2039ca726159a6493d3bdd) Thanks [@mortegro](https://github.com/mortegro)! - `skaile manage` now groups assets `publisher → domain → asset`, surfacing sidecar
  `DOMAIN.md` navigational domains. Repo provenance moves from a grouping level to a
  dim per-asset badge. Domain headers are navigational (collapse/expand + a `[d]`
  info region showing the domain description, relation badge, recommended pick, and
  sidecar source) and never bulk-install. Per-asset relation glyphs (`[add]`/`▷alt`/
  `⇣N`) and a `★` recommended marker render from the domain's metadata.

  Adds the `CatalogDomain` type and `DomainRelation` union to `@skaile/workspaces/core`,
  and a `domains` channel on the manage feed (`AssetFeedResult.domains`), populated
  when the connected catalog source exposes `listDomains` and empty otherwise — so a
  domain-free catalog renders exactly like the previous flat publisher list.

### Patch Changes

- [#137](https://github.com/skaile-ai/workspaces/pull/137) [`742e6d8`](https://github.com/skaile-ai/workspaces/commit/742e6d87b3b797eaad77c3a5e1ebc8e1e55bc73b) Thanks [@peteralbert](https://github.com/peteralbert)! - fix(connectors): ungate on dispose when a gate is still in-flight

  `FleetHealthGate.dispose()` now also handles the in-flight gate window. While an `establish()` is pending, `gated` is still `false` but `pendingGated` is `true`; the old guard returned the in-flight promise without enqueuing an `ungate()`, leaking the read-only barrier once the gate finished. `dispose()` now clears `pendingGated` and enqueues `ungate()`, which chains after the in-flight establish via the existing transition queue.

- [#139](https://github.com/skaile-ai/workspaces/pull/139) [`6d37639`](https://github.com/skaile-ai/workspaces/commit/6d37639bcebfd4e1fc24fe2a45c3d8c223b762ce) Thanks [@peteralbert](https://github.com/peteralbert)! - fix(connectors): tolerate a live/broken FUSE target in gate establish

  `RoMountPlaceholderBarrier.establish()` no longer throws when its target is a live `:rshared` FUSE bind or a broken one. The `mkdir(target, { recursive: true })` step used to surface a raw `EEXIST` (live/broken mountpoint the kernel reports as existing but Node can't stat as a fresh dir) or `ENOTCONN` ("Transport endpoint is not connected") straight out of `establish()` and out of `FleetHealthGate.gate()`. It now tolerates `EEXIST`/`ENOTCONN`/`EBUSY`/`ENOTDIR` and proceeds to the protect step; any other mkdir failure degrades via the existing `chmodFallback` instead of throwing. The tmpfs-over-mount path already degrades to chmod (and then to a tracked, loudly-logged "unprotected" state) on failure, so a gate-establish attempt over a thrashed fleet bind can never throw a raw error or further corrupt the mount. Fail-closed semantics are preserved.

## 0.34.0

### Minor Changes

- [#134](https://github.com/skaile-ai/workspaces/pull/134) [`4dc5050`](https://github.com/skaile-ai/workspaces/commit/4dc5050eeafa3ea0e28c857a732df57d6a7bad2d) Thanks [@peteralbert](https://github.com/peteralbert)! - Add a health-aware read-only gate for fleet-managed mounts (B-180). A fleet
  mount is bound live by the host fleet rclone and so is excluded from the
  boot-time no-phantom-workspace barrier; that exclusion is only safe while the
  fleet rclone is healthy. When it wedges (`EIO`, or a silently-empty-but-writable
  degradation), `FleetHealthGate` now dynamically establishes the same
  `MountPlaceholderBarrier` over the live target until the mount recovers,
  closing the window where a wedged fleet mount would let the agent write a
  divergent phantom workspace.

  Two detectors feed one debounced, fail-closed gate: a self-contained local
  `stat`/`readdir` probe (catches EIO / dead-bind) and a platform health signal
  consumed from `<workspace>/.skaile/fleet-health/<id>` via the new
  `ConnectorManager.reportFleetHealth(id, verdict)` (catches silent degradation).
  Healthy fleet mounts stay writable exactly as before. Poll interval is
  `SKAILE_FLEET_HEALTH_POLL_MS` (default 5000; 0 disables the local probe). See
  `MIGRATION.md` for the optional platform emit-side contract.

- [#135](https://github.com/skaile-ai/workspaces/pull/135) [`6cc1a07`](https://github.com/skaile-ai/workspaces/commit/6cc1a073f73a56ae0ed5128ecc294f95b312bd6d) Thanks [@peteralbert](https://github.com/peteralbert)! - Runner-managed remote MCP servers now recover from a mid-session 401. When a
  tool call (or the initial connect) on an `auth: backend` http/sse MCP server is
  rejected with a 401, `ExternalMcpManager` re-mints the bearer through the
  platform mediator (`host.refresh_credential { kind: 'mcp-server', reason:
'retry-401' }`), rewrites the provisioned `MCP__<id>__AUTH` secret, rebuilds the
  transport, and reconnects the client in place — then retries the call once.
  Concurrent 401s for the same server share one re-mint round-trip (per-server
  in-flight gate), and capability registrations stay stable across the swap.

  `McpServerDeclaration` gains two optional fields — `auth` and `providerLinkId` —
  modelling the remote-server credential metadata the platform already writes into
  `mcp_servers` entries (additive; existing declarations are unaffected).

  Pairs with the platform's real remote-MCP OAuth token-endpoint refresh.

## 0.33.0

### Minor Changes

- [#133](https://github.com/skaile-ai/workspaces/pull/133) [`c8cae51`](https://github.com/skaile-ai/workspaces/commit/c8cae51e909f1d1bc98cc8eab646083a5d29e9c5) Thanks [@peteralbert](https://github.com/peteralbert)! - Retire the `SKAILE_STRICT_YAML` flag — strict asset loading is now the only
  behavior. The runner activates ONLY `skaile.yaml`-declared assets;
  materialized-but-undeclared on-disk MCP servers and skills ("ghosts") are never
  picked up. Previously this was opt-in (default off) but production agent
  containers always set the flag, so production behavior is unchanged — this just
  makes the canonical-contract invariant hold unconditionally and removes the last
  Sprint-1 loose end.

  - `loadMcpServerDeclarations` no longer reads `process.env.SKAILE_STRICT_YAML`;
    `upsertMaterializedMcpDecls` always filters by the declared set.
  - `stageMaterializedSkills`'s `opts.strict` boolean is dropped — filtering is now
    driven by the presence of `opts.declared` (when omitted, the scan stages all,
    for non-runner utility callers). The runner (`serve.ts`) always passes the
    declared skill set.

  See `MIGRATION.md`. No action for the platform (it already writes the full
  effective set and set the flag).

- [#133](https://github.com/skaile-ai/workspaces/pull/133) [`c8cae51`](https://github.com/skaile-ai/workspaces/commit/c8cae51e909f1d1bc98cc8eab646083a5d29e9c5) Thanks [@peteralbert](https://github.com/peteralbert)! - Align the `runner.lifecycle` capability with the platform's wire shape and wire
  its handler to real operations. The cap previously declared
  `{ action: 'hibernate' | 'close' | 'shutdown' }` but the platform invokes it with
  `{ phase: 'compact' | 'hibernate' | 'close' }` — so every lifecycle invoke
  Zod-rejected (key `action`≠`phase`, value `compact` ∉ enum) and, even had it
  parsed, the handler was an unimplemented stub. Hibernate/compact signaling to
  connectors therefore never fired (silently, since the platform sites are
  non-fatal).

  The canonical contract is now `{ phase: 'hibernate' | 'close' | 'compact' }`
  (`shutdown` was never sent and is dropped). The handler is wired via the new
  `buildLifecycleHandler` factory: `compact` runs the compaction orchestrator
  (`trigger: 'manual'`), `hibernate` runs `ConnectorManager.hibernateAll()`
  (`onHibernate` persistence), `close` runs `ConnectorManager.closeAll()`
  (`onSessionClose` finalization). A runner-side contract test pins the schema to
  the platform payloads so the two halves can't re-drift.

  No consumer action: the platform already sends the `{ phase }` shape, so shipping
  this runner build simply makes lifecycle signaling work instead of failing
  silently.

### Patch Changes

- [#130](https://github.com/skaile-ai/workspaces/pull/130) [`3fb8f8e`](https://github.com/skaile-ai/workspaces/commit/3fb8f8ea7fd7bc6b68d36bf11764f6786e178f7a) Thanks [@peteralbert](https://github.com/peteralbert)! - Fix: claude-sdk driver now authenticates API-key provider configs.

  The `claude-sdk` bridge driver passed a resolved Anthropic API key to the Claude
  Agent SDK as the top-level `query({ apiKey })` argument, which the SDK ignores —
  it authenticates only via the `ANTHROPIC_API_KEY` environment variable or an
  OAuth credentials file. As a result, any cloud session backed by an `ApiToken`
  AI-provider config silently fell back to OAuth login mode and failed with
  "Not logged in" (no creds file) or "401 Invalid authentication credentials"
  (stale creds file) — the provisioned key was never used.

  `startQuery()` now injects the resolved key into the SDK child-process env
  (`options.env.ANTHROPIC_API_KEY`, spread over the current env so PATH/HOME are
  preserved) whenever an API key is configured. The OAuth path is unchanged: when
  no key is resolved, no env key is set and the SDK uses `~/.claude/.credentials.json`
  via `settingSources: ["user"]`.

- [#128](https://github.com/skaile-ai/workspaces/pull/128) [`ea2c155`](https://github.com/skaile-ai/workspaces/commit/ea2c15590a22c15ace392b5e06d1ec133a3d49ab) Thanks [@peteralbert](https://github.com/peteralbert)! - Proactive + reactive OAuth token refresh for rclone-backed mounts (sharepoint / onedrive / webdav).

  rclone-backed mount drivers baked a single access token into the rclone INI at `connect()` and never refreshed it. For Skaile-managed Entra links the backend strips the `client_secret` so rclone cannot self-refresh, so the access token expired ~1h after spawn and every read/write failed with `EIO` (`invalid_client`). Git mounts and AI credentials already had a refresh mechanism; this brings rclone mounts to parity.

  `@skaile/workspaces/connectors` gains `createRcloneTokenRefresh` — a per-mount controller that arms a proactive timer firing `marginMs` (5 min) before token expiry, re-mints through the backend `TokenMediator`, re-renders the INI, and recycles the rclone process via the new `RcloneProcessManager.reconfigure(handle, config)`. A reactive `onAuthError()` hook — fed by rclone's own auth-failure log lines (`invalid_client` / `couldn't fetch token` / `InvalidAuthenticationToken`), detected in `_routeRcloneLogLine` via the new `RcloneSpawnConfig.onAuthError` — is the safety net, guarded by an in-flight lock + cooldown. New `resolveMountCredential(declaration, secrets, tokenMediator, reason)` returns `{ token, expiresAt }`; `resolveMountCredentialString` now delegates to it. Standalone `env:`/inline auth (no mediator, no expiry) stays static — no refresh armed.

## 0.32.0

### Minor Changes

- [#126](https://github.com/skaile-ai/workspaces/pull/126) [`edda854`](https://github.com/skaile-ai/workspaces/commit/edda85442624bae0443216cd242b3227e577c295) Thanks [@peteralbert](https://github.com/peteralbert)! - First-class connector mode (agent|sync) in skaile.yaml + git driver derives session/sync/lifecycle defaults.

  Adds an optional flat-layout mode to `installFromManifest` (`@skaile/workspaces/install`): pass `targetDir` and install-core reproduces the platform pointer-installer's behavior — strips the longest-common-dir prefix, per-file sha256-verifies, and writes the stripped paths as immediate children of `targetDir`, with no ref parse and no composite verify. This makes install-core the single canonical installer so the platform can retire its duplicated `installPointer`. Omitting `targetDir` is unchanged: the existing ref-derived `@<pub>/name/version/<path>` layout + composite verify remains the default. Also exports a `longestCommonDirPrefix` helper.

## 0.31.0

### Minor Changes

- [#124](https://github.com/skaile-ai/workspaces/pull/124) [`a1afc6a`](https://github.com/skaile-ai/workspaces/commit/a1afc6a8e4a5f347619f5c75d80a29f38994563a) Thanks [@peteralbert](https://github.com/peteralbert)! - MCP launcher is now a pluggable, named strategy.

  `runner`'s `recipe-resolver` is generalized behind a `RecipeProducer` seam — a content-addressed store-path map (`MapRecipeProducer`) and a nix builder (`NixRecipeProducer`) are now two producers behind one interface (`resolveViaProducers`); nix is one producer of the map, not the only resolution path. `resolveRecipePath` is unchanged for existing callers (thin back-compat wrapper).

  `McpServerDeclaration` (`@skaile/workspaces/core`) gains an optional `launcher: "recipe" | "package-runner" | "path"`. Only `recipe` carries resolution logic (the producer seam); `package-runner` (npx/uvx/pip) and `path` are documented command-shapes the stdio transport already spawns as-is. `launcher: "recipe"` without a `recipe` binding is a logged drop; an unknown `launcher` value is stripped with an `unknown_launcher` diagnostic. No wire/transport changes — remote (sse/http) MCP is unaffected.

### Patch Changes

- [#123](https://github.com/skaile-ai/workspaces/pull/123) [`2c7fe23`](https://github.com/skaile-ai/workspaces/commit/2c7fe23e751bd9efd6ad0af11033f2489deeb2d3) Thanks [@peteralbert](https://github.com/peteralbert)! - Fix: claude-sdk driver no longer leaks a recovered 401 to the user. Claude Code
  renders an upstream `authentication_error` as a standalone assistant text
  message ("Failed to authenticate. API Error: 401 ...") just before failing the
  turn. The existing error-event deferral only suppressed the structured `error`
  agent-event, so that assistant text still flowed through the normal message
  channel and was persisted/shown even when the self-heal retry recovered
  cleanly. The driver now suppresses a text-only assistant message that
  classifies as `auth` while a self-heal is still available (`onAuthError` wired
  and the auth budget unspent); the retried attempt re-emits the real content.
  Turns that genuinely succeed and messages carrying tool calls are unaffected.

## 0.30.1

### Patch Changes

- [#120](https://github.com/skaile-ai/workspaces/pull/120) [`72e099c`](https://github.com/skaile-ai/workspaces/commit/72e099cc09577d0c0c3c8f2445927fc8c880eaf5) Thanks [@peteralbert](https://github.com/peteralbert)! - Emit `session_init_ack` before the slow connector-connect / external-MCP-spawn bring-up so a cold or resource-heavy session no longer blows the platform's 5s session_init ack budget (which previously triggered an unbounded container-respawn loop). Heavy resource bring-up now runs in the background; prompts continue to await session readiness.

## 0.30.0

### Minor Changes

- [#108](https://github.com/skaile-ai/workspaces/pull/108) [`dd48d44`](https://github.com/skaile-ai/workspaces/commit/dd48d44e4dd5d72ba92b47af3788c5ba70d18e3c) Thanks [@mortegro](https://github.com/mortegro)! - Thread `domainSlug` through the catalog client surface: `CatalogEntry`
  (core, with round-trip in `entryFromRaw`/`entryToRaw`) and `CatalogAsset`
  (plugins) gain an optional display-only `domainSlug`; the remote + REST catalog
  normalizers and the CLI store asset-feed carry it through. Foundation for
  domain-grouped browsing in `skaile manage` (TUI grouping is a follow-on).

- [#108](https://github.com/skaile-ai/workspaces/pull/108) [`963fa56`](https://github.com/skaile-ai/workspaces/commit/963fa56c921d94d4defffa651f26de7d283c006a) Thanks [@mortegro](https://github.com/mortegro)! - Discovery: navigational domains from sidecar `DOMAIN.md`.

  `discoverAssetsInTree` and `discoverAssetsInTreeEntries` now resolve `DOMAIN.md`
  descriptors (explicit `domainFiles`, else an in-tree/sidecar scan), stamp a
  display-only `domainSlug` on each `DiscoveredAsset`, and surface an ordered
  `DiscoveryResult.domains` index. A `DOMAIN.md` claims its directory plus
  everything below it (whole-segment containment); domains are strictly
  non-nested. New pure helpers in `@skaile/workspaces/discovery`: `parseDomainMd`,
  `validateDomains`, `assignDomain`, `buildDomainIndex`, `applyDomains`. Domain is
  never part of asset identity or the flat install layout.

### Patch Changes

- [#119](https://github.com/skaile-ai/workspaces/pull/119) [`fe1066f`](https://github.com/skaile-ai/workspaces/commit/fe1066f746ea2eaf88e0618998a12df399640deb) Thanks [@peteralbert](https://github.com/peteralbert)! - Create the per-session `.skaile/logs.db` (and WAL/SHM siblings) group-writable (0664) so a co-grouped host process (the platform backend, gid skaile/1000) can run logs retention against it. Previously it was created 0644 and the backend's retention open failed read-only.

- [#115](https://github.com/skaile-ai/workspaces/pull/115) [`ed78a7d`](https://github.com/skaile-ai/workspaces/commit/ed78a7d1f9e9de7cca8be861c6198b600aa85ba1) Thanks [@mortegro](https://github.com/mortegro)! - Internal code-quality cleanup guided by `fallow`: removed dead code (3 orphan dev
  scripts, an unused integration test setup, ~15 internal-only exports/re-exports, 2
  unused private methods, and the unused `picomatch` dependency) and reduced cognitive
  complexity across 48 source files via behavior-preserving extract-function refactors
  (e.g. `serve.ts` `handleCommand` 336→thin dispatcher; `workspace-config.ts`,
  `publish-manifest.ts`, `claude-sdk.ts`, and many others). No public API, exported
  signatures, or runtime behavior changed; the full test suite (3121 tests) passes
  unchanged.

## 0.29.1

### Patch Changes

- [#116](https://github.com/skaile-ai/workspaces/pull/116) [`7af1737`](https://github.com/skaile-ai/workspaces/commit/7af17379e9776b7d3b648a2c229cc0638dc5aaf3) Thanks [@peteralbert](https://github.com/peteralbert)! - git connector: reuse an existing local session branch on connect instead of
  failing with `checkout -b ... already exists`. A prior connect (e.g. a forked
  session whose first connect failed before pushing) can leave the session branch
  created locally but absent from origin; the driver now checks out the existing
  local branch as-is, preserving its unpushed commits, rather than bricking the
  required read-write workspace mount on every subsequent wake.

## 0.29.0

### Minor Changes

- [#113](https://github.com/skaile-ai/workspaces/pull/113) [`c3177d7`](https://github.com/skaile-ai/workspaces/commit/c3177d7ec46a6784c563892db616551b9ca81e4a) Thanks [@peteralbert](https://github.com/peteralbert)! - The rclone mount drivers (`sharepoint`, `googledrive`, `webdav`) now accept
  `auth: backend`. When declared, each driver pulls its credential from the
  session's pre-minted credentials map via the connector token mediator (the same
  `request_access_token` → `initial` round-trip the `git` driver uses) instead of
  reading an env/secret ref. The mediator delivers the credential in the existing
  `token` field as the same shape the legacy `*_TOKEN` env path already used — a
  JSON blob for sharepoint/googledrive, a `username:appPassword` or bearer string
  for webdav/nextcloud — and the drivers feed it into their existing
  `parseAuthBlob` / `parseAuth` parser unchanged (no wire-type change).

  Legacy `env:`/inline auth is unchanged: standalone CLI and non-platform users
  keep working untouched, and those paths never touch the token mediator. Unlike
  the git driver, the rclone drivers schedule **no** refresh tick — rclone
  self-refreshes from the `refresh_token`/`client_*` fields baked into the INI, so
  the mediator only has to deliver a valid blob at connect time.

  A shared `resolveMountCredentialString(declaration, secrets, tokenMediator)`
  helper (plus `MountTokenMediatorError`) is exported from
  `@skaile/workspaces/connectors` for this branch. The googledrive and webdav
  drivers also gain the `SKAILE_RCLONE_CACHE_BASE` env override the sharepoint
  driver already had, so all three rclone VFS cache roots can follow the host
  bind-mount uniformly.

## 0.28.1

### Patch Changes

- [#109](https://github.com/skaile-ai/workspaces/pull/109) [`952ba41`](https://github.com/skaile-ai/workspaces/commit/952ba418326e8dcecf34b2ddf5ae3db6cfbdcc25) Thanks [@peteralbert](https://github.com/peteralbert)! - Fix discoverable composition entries being silently dropped when the local
  asset index errors. `resolveComposition`'s discoverable path now treats the
  library lookup as optional description enrichment: a failure to open or query
  the index (missing/corrupt/migrating `~/.skaile/index.db`) falls back to the
  default description instead of aborting the entry, so skills/connectors still
  register as capabilities.

## 0.28.0

### Minor Changes

- [#110](https://github.com/skaile-ai/workspaces/pull/110) [`2d90fd6`](https://github.com/skaile-ai/workspaces/commit/2d90fd67dc3dd6292bd82efd7b891c05ad785725) Thanks [@peteralbert](https://github.com/peteralbert)! - Rename the asset-library `subscription` concept to `assignment`: the LocalIndex/`IAssetIndex` methods `subscribe`/`unsubscribe`/`listSubscriptions` → `assign`/`unassign`/`listAssignments`; types `Subscription`/`LockSubscription` → `Assignment`/`LockAssignment`; errors `DuplicateSubscriptionError`/`SubscriptionNotFoundError` → `DuplicateAssignmentError`/`AssignmentNotFoundError`; the SQLite `subscriptions` table → `assignments` (one-shot auto-migration on open); and the `skaile.lock.yaml` `subscriptions:` field → `assignments:` (legacy key still read). Pure rename — no behavior change.

## 0.27.0

### Minor Changes

- [#101](https://github.com/skaile-ai/workspaces/pull/101) [`d72e752`](https://github.com/skaile-ai/workspaces/commit/d72e752e9a68c00b4b87eb107d554c50522cd410) Thanks [@peteralbert](https://github.com/peteralbert)! - Add five new agent backends — **Gemini CLI** (`gemini`), **Qwen Code** (`qwen`),
  **OpenCode** (`opencode`), **Goose** (`goose`), and **Continue CLI**
  (`continue`) — wrapping each tool's headless / structured-output mode behind the
  `AgentDriver` contract.

  - **New shared base** `bridge/src/drivers/_subprocess-cli.ts`
    (`SubprocessCliDriver`) for one-shot CLI backends: spawn-per-turn, stream
    newline-delimited JSON on stdout → `AgentEvent`s, process exit = turn end.
    Session continuity is captured (`session_info`) and replayed via each CLI's
    resume flag.
  - **New subpath exports**: `@skaile/workspaces/drivers/{gemini,qwen,opencode,goose,continue}`,
    each registered into `pluginRegistry` by `registerBuiltinDrivers()` and listed
    in `BUILTIN_DRIVER_CATALOG` / `listDrivers()`.
  - These drivers spawn an external binary the user installs separately, so they
    bundle **no SDK** and carry **no `bun --compile` build constant** — always
    available, like `omp`/`echo`. A missing binary surfaces as a `config` error
    with an install hint.
  - `qwen`, `opencode`, `goose`, and `continue` are **model-agnostic** (only `omp`
    was before); `gemini` adds first Google/Gemini coverage.

  Additive only — no existing API changed. Caveats (Gemini headless resume,
  version-gated `--output-format` for Gemini/Goose, Continue's buffered-only
  output) and the design are documented in
  `_devlog/specs/2026-06-06-cli-agent-backends.md`.

- [#105](https://github.com/skaile-ai/workspaces/pull/105) [`25f2763`](https://github.com/skaile-ai/workspaces/commit/25f2763a750b92cd6c6255ce4bf3abb3074bbb14) Thanks [@mortegro](https://github.com/mortegro)! - `skaile manage` Assets tab now groups assets by **origin** — Library / Sources / Store — as a top-level collapsible band (`origin → publisher → repo → asset`), so it's clear where each skill comes from. The Store section is default-collapsed (local assets stay front-and-centre), and number keys `1`/`2`/`3` filter the list to a single origin (`0` clears). Origin is also shown in the asset info panel.

- [#106](https://github.com/skaile-ai/workspaces/pull/106) [`8b7b87c`](https://github.com/skaile-ai/workspaces/commit/8b7b87c6ca9c10ec7dce5a85bde49cb5f965eb87) Thanks [@peteralbert](https://github.com/peteralbert)! - strict-yaml: runner activates only declared MCP servers (opt-in via SKAILE_STRICT_YAML)

- [#106](https://github.com/skaile-ai/workspaces/pull/106) [`8b7b87c`](https://github.com/skaile-ai/workspaces/commit/8b7b87c6ca9c10ec7dce5a85bde49cb5f965eb87) Thanks [@peteralbert](https://github.com/peteralbert)! - strict-yaml: skill staging is declaration-driven (opt-in via SKAILE_STRICT_YAML)

### Patch Changes

- [#104](https://github.com/skaile-ai/workspaces/pull/104) [`f876a25`](https://github.com/skaile-ai/workspaces/commit/f876a2535dc0269431ce8a6fc03fad596e7b0fe4) Thanks [@mortegro](https://github.com/mortegro)! - Fix `skaile manage` table alignment for long asset kinds (e.g. `mcp-server`). `kindColorPad` now fits the kind to an exact column width (pads short, truncates over-long) instead of only padding, and the kind column widened to fit the longest kind.

- [#106](https://github.com/skaile-ai/workspaces/pull/106) [`8b7b87c`](https://github.com/skaile-ai/workspaces/commit/8b7b87c6ca9c10ec7dce5a85bde49cb5f965eb87) Thanks [@peteralbert](https://github.com/peteralbert)! - Expose DB-free install-core via `./install` subpath.

  Adds `@skaile/workspaces/install` re-exporting `installFromManifest`,
  `fetchAssetFilesFromGitHub`, and their public types (`AssetFetcher`,
  `InstallOpts`, `InstallResult`, `FetchOpts`, `GITHUB_PAT_ENV`). The
  install module has no `LocalIndex` / `@libsql/client` dependency, making
  it safe to import from environments that do not carry the full library
  stack (e.g. the platform backend).

## 0.26.0

### Minor Changes

- [#99](https://github.com/skaile-ai/workspaces/pull/99) [`fb76100`](https://github.com/skaile-ai/workspaces/commit/fb76100d1524784ccfafb52520280e1764e0c38a) Thanks [@peteralbert](https://github.com/peteralbert)! - New capabilities from the code-review follow-up:

  - **store resolution**: `ICatalogSource` gains a `listVersions(ref)` method (implemented
    for the local, tRPC, and REST catalog sources), and the resolver now uses it to
    discover store-available versions when an asset has no source clone — so store-only
    assets resolve for bare and ranged refs, not just exact-version pins.
  - **library**: the instance-picker `kind` filter is now honored end-to-end —
    `InstanceFilter.kind` → `listInstances` (kind derived from the asset definition) →
    `queryInstancePickerChoices`.
  - **bridge**: the omp driver's `prompt()` now resolves after the turn completes
    (`agent_end`) and rejects if the process exits first, honoring the `AgentDriver`
    contract that `claude-sdk`/`codex` already follow.

  Security and correctness fixes from a whole-repo code review.

  - **runner**: fix a path-traversal in the resource mount guard — the prefix-based
    `startsWith` check is replaced with a boundary-aware `path.relative()` test, so a
    request can no longer escape into a sibling directory sharing a string prefix.
  - **runner**: `git commit -m <message>` in workspace migration now uses argv-form
    `execFileSync` instead of a shell string, neutralising command substitution in the
    wire-supplied target folder name.
  - **deploy**: a failed `portableSpawn` (`pid: -1`) can no longer reach
    `process.kill(-1, …)` — local/nix targets reject an invalid pid at `create()` and
    `process-handle` treats `pid <= 0` as not-alive / a no-op kill.
  - **secrets**: `setKeyStrategy` now aborts the key-strategy switch when any secret
    fails to re-encrypt, instead of committing the new strategy and orphaning secrets
    under the old key.
  - **telemetry**: `env:`-prefixed `auth` / `secret_key` config values are now
    dereferenced against `process.env` instead of being base64-encoded literally.
  - **factory-assets**: SQL identifier quoting in the postgres/sqlite connectors now
    escapes embedded quotes; `matchGlob` (memory / xstate-store) escapes regex
    metacharacters; the Mattermost channel filter is enforced on by-ID post/thread
    reads; a dead `blocked`-status branch is removed from the flow adapter.
  - **connectors**: the CLI no longer scatter-assigns one positional argument to
    `sql`/`key`/`message`/`query`; credential tokens are percent-encoded so a token
    containing `@` round-trips correctly.
  - **discovery**: unquoted numeric `version` values are coerced to strings instead of
    silently falling back to `0.1.0`; `manifest.name` is validated before use; empty
    frontmatter on a kind-matched manifest is treated as a parse error.
  - **resolver**: prerequisite inputs keyed by `key:` keep their identity
    (`id ?? key`); `parseCompactionDirectives` guards a missing trailing newline.
  - **types**: the skill `prerequisites` schema no longer strips
    `reads`/`produces`/`resources`/`connectors`.
  - **bridge**: shared provider-endpoint config and a shared generic error classifier
    remove duplication across drivers; `findClaudeBinary` is memoised.
  - **cli**: `skaile init` emits the driver/provider/model matching the chosen
    `--backend`; assorted smaller fixes (config re-parse, `--limit` coercer, pre-commit
    hook uses argv-form git).
  - **sdk**: the temp workspace now uses `mkdtempSync` instead of a predictable
    `/tmp/skaile-sdk/<timestamp>` path.
  - Misc cleanups in `core`, `library`, and `asset-manager`.

- [#100](https://github.com/skaile-ai/workspaces/pull/100) [`d3cddc7`](https://github.com/skaile-ai/workspaces/commit/d3cddc7acf8a259923aae0755b85e566472130d4) Thanks [@mortegro](https://github.com/mortegro)! - Resolve the built-in `factory-assets` tree uniformly across every consumer, and
  make `SKAILE_FACTORY_ASSETS_DIR` an **additive override layer** instead of a
  wholesale replacement.

  Previously the env override (`SKAILE_FACTORY_ASSETS_DIR`, legacy
  `SKAILE_BASE_ASSETS_DIR`) was honoured only by the connector catalog
  (`resolveFactoryAssetsRoot`), while skill / flow / completion discovery and the
  asset-manager factory source went through a separate `resolvePackagedAssetsDir`
  call that ignored it — so a container pointing the var at `/app/factory-assets/`
  relocated connectors but silently left skills/flows reading the bundled tree.

  Now a single shared resolver `factoryAssetRoots()` (exported from
  `@skaile/workspaces/core`) returns an ordered list — the env dir (when set and
  existing) **in front of** the always-present bundled tree — and every consumer
  uses it. Override semantics are **env-first**: on a name clash the env layer wins
  and the bundled tree fills the rest. The bundled baseline is always present, so a
  missing/misconfigured env dir can no longer blank out built-ins.

  The override layer can add or refresh **skills, flows, bundles, and connector
  manifests/metadata**, but cannot ship a new _executable_ connector — connector
  code loads via the package's static `exports` map, not the resolved dir.

  `resolveFactoryAssetsRoot()` is retained as a thin wrapper returning the
  highest-precedence root for single-dir callers. See `MIGRATION.md` for the
  behavior-change note.

- [#95](https://github.com/skaile-ai/workspaces/pull/95) [`38aa24e`](https://github.com/skaile-ai/workspaces/commit/38aa24eb3eebebe70623c7bd663c8e9b9469b132) Thanks [@mortegro](https://github.com/mortegro)! - Unify all built-in **content** into one first-party tree, `factory-assets/`, that
  ships inside the package and works after `npm i @skaile/workspaces` (CLI and SDK).
  The former `base-assets/` connector folder and the repo-root `ai-assets/` skills
  tree are now colocated and co-delivered as a single `dist/factory-assets/`.

  - **One source tree, one delivery, one resolver.** `factory-assets/` holds
    `connectors/` (compiled by tsup) + `skills/` + `bundles/` (copied by
    `_scripts/copy-packaged-assets.ts`) + the publication `skaile.yaml`. Everything
    resolves through the single `resolvePackagedAssetsDir("factory-assets")` /
    `resolveFactoryAssetsRoot()` helper (in-repo source during dev, `dist/` after
    install). The old dual `dist/base-assets/` + `dist/factory-assets/` split and
    the separate `resolveBaseAssetsRoot()` are gone.

  - **Factory source** (skills): `AssetManager` registers `factory-assets/` as an
    implicit, always-on source — `skaile search` / `add` / `manage` surface the
    first-party skills with zero config, no clone. Keyed by the declared publisher.

  - **Discovery unified.** Skill/flow discovery (`findSkills`, flow discovery,
    completion resolvers) resolves through the same `factory-assets` helper in both
    dev and installed contexts — no more monorepo special-case. The CLI monorepo
    marker is now `biome.json` (repo-root tooling) and only gates dev-only commands.

  - **BREAKING — published export renamed.** The `@skaile/workspaces/base-assets`
    subpath export (and all `@skaile/workspaces/base-assets/connectors/*`
    sub-exports, incl. `.../connectors/flow/engine`) is renamed to
    `@skaile/workspaces/factory-assets/...`. Consumers must update their imports.

  - **BREAKING — env var renamed (with fallback).** `SKAILE_BASE_ASSETS_DIR` →
    `SKAILE_FACTORY_ASSETS_DIR`. The old name is still read as a deprecated
    fallback, so existing agent containers keep working until they migrate.

  - Renamed exports in `@skaile/workspaces/core`: `resolveBaseAssetsRoot` →
    `resolveFactoryAssetsRoot`, `BASE_ASSETS_REPO_NAME` → `FACTORY_ASSETS_REPO_NAME`.

  - **base-assets manifest-publish fix (carried over):** the `CONNECTOR.md`
    manifests were never published (`files: ["dist"]` ships only compiled `.js`),
    so after `npm i` the connector root resolved to a dir with no manifests and
    `skaile connector catalog` / `add` and the runtime connector scan broke. The
    manifests now ship in `dist/factory-assets/`.

- [#91](https://github.com/skaile-ai/workspaces/pull/91) [`7229be9`](https://github.com/skaile-ai/workspaces/commit/7229be9564164bca5625a4f228e24468fada172d) Thanks [@mortegro](https://github.com/mortegro)! - Split publication out of `skaile.yaml` into a new `skaile.manifest.yaml`.

  `skaile.yaml` is now workspace/consumption-only. The publication keys
  `publisher` / `version` / `assets` move to a dedicated `skaile.manifest.yaml`
  (`SkPublishManifest`, decoded via the new `@skaile/workspaces/core/publish-manifest`
  codec). Each asset gains a per-asset dependency graph (`assets[].dependencies`,
  canonical refs) plus store-listing metadata (`description` / `keywords` /
  `category` / `license` / `homepage`, manifest-wins-else-frontmatter), and an
  optional curator `source:` / `provenance:` pointer for repos that publish bytes
  they don't own.

  **Breaking (hard cut):** `publisher:` / `version:` / `assets:` in `skaile.yaml`
  are now rejected at parse time (`decodeSkaileYaml` records a `legacy_key_rejected`
  error diagnostic; `resolveSkWorkspaceConfig` re-throws it). The provenance walker
  reads `skaile.manifest.yaml` (recursively — one manifest per curated upstream) or
  the agentskills.io filename convention; there is no `skaile.yaml` publication
  fallback. `repo-manager`'s `declaredPublisher` and `skaile npx`'s
  `resolvePublisher` read `skaile.manifest.yaml`. `WorkspaceYamlEditor` drops its
  publication accessors; the new `ManifestYamlEditor` owns them. Migrate with the
  `migrate-skaile-manifest` skill.

  The author discovery inventory (formerly `.skaile-source.yaml`) and the
  sidecar/curator source-manifest now also read `skaile.manifest.yaml`: `discovery`
  adapts it via `manifestToSourceConfig` (inventory → manifest mode, else glob mode
  under the repo-level `publisher`); a `root`-only asset entry is materialised by a
  recursive walk. The `library` sidecar overlay and `~/.skaile/store` migration
  move to `skaile.manifest.yaml` (sidecar lock renamed `skaile.manifest.lock.json`;
  the legacy `.skaile-source.yaml` filename is still migrated for back-compat).
  Store-side ingestion of `skaile.manifest.yaml` over GitHub Trees/Blobs remains a
  companion `skaile-store` follow-up.

- [#90](https://github.com/skaile-ai/workspaces/pull/90) [`22b44fb`](https://github.com/skaile-ai/workspaces/commit/22b44fbd2956067113b773c82fd79ec9f1a0e196) Thanks [@peteralbert](https://github.com/peteralbert)! - Hold a not-ready deferred remote mount read-only at the filesystem layer.

  When a deferred rclone-backed mount (sharepoint/webdav/googledrive) is pending
  or has failed, `ConnectorManager` now establishes a read-only barrier (a
  read-only tmpfs over the target, with a `chmod 0o555` fallback) at the mount
  target synchronously in the eager phase of `connectAll` — before
  `session_init_ack` — so the agent can no longer write a phantom workspace into a
  not-yet-live remote. The real rclone mount shadows the placeholder once it comes
  up; on teardown the placeholder is unmounted after the mount above it. Local and
  fleet-managed mounts stay writable and are never gated. The prompt and
  `connector_status` channel surface the not-ready mount as read-only/unavailable.

  New additive exports from `@skaile/workspaces/connectors`: `MountPlaceholderBarrier`,
  `RoMountPlaceholderBarrier`, `defaultMountPlaceholderBarrier`, `NOT_READY_SENTINEL`,
  and a `placeholderBarrier` option on `ConnectorManagerOptions`.

### Patch Changes

- [#99](https://github.com/skaile-ai/workspaces/pull/99) [`1d829f7`](https://github.com/skaile-ai/workspaces/commit/1d829f75c9a4a5f1ae682907f5b30028ab4e5f88) Thanks [@peteralbert](https://github.com/peteralbert)! - Two small code-review follow-ups:

  - **runner**: outbound `host.*` capability calls now honour a per-capability
    `callTimeoutMs` instead of being hard-capped at the 60s default, mirroring the
    inbound `resolveCapabilityCallTimeoutMs` path. `CapabilityRegistry.invokeRemote`
    threads the registered capability's `callTimeoutMs` through the remote invoker.
  - **session**: drop the redundant final sort in `parseMentions` (`String.matchAll`
    already yields matches in offset order).

- [#93](https://github.com/skaile-ai/workspaces/pull/93) [`9ab9717`](https://github.com/skaile-ai/workspaces/commit/9ab9717f12c0afe999050d03864dc735e24a8b75) Thanks [@mortegro](https://github.com/mortegro)! - Migrate the built-in deploy targets onto the shared `makeDeployHandle` factory.

  The `local`/`nix` process targets (`buildProcessHandle`) and the `docker`/`podman`
  container target (`buildHandle`) were the last copies of the hand-rolled
  `DeployHandle` wrapper. Both now build their handle through
  `@skaile/workspaces/plugin-registry`'s `makeDeployHandle`, removing the duplicated
  `state` getter, `waitReady` ready→errored block, and `wsAuth` redaction. As a
  result `waitReady(timeoutMs)` is now honored (forwarded to the readiness routine)
  instead of being discarded by these targets.

- [#97](https://github.com/skaile-ai/workspaces/pull/97) [`5be47c1`](https://github.com/skaile-ai/workspaces/commit/5be47c1f2a69c333ba323759b09265635a9c75fc) Thanks [@mortegro](https://github.com/mortegro)! - Fix follow-ups to the `factory-assets/` rename:

  - **`tsconfig.build.json`** still referenced the deleted `base-assets/*` dirs and
    omitted `factory-assets/` from `include`, so `build:dts` emitted no `.d.ts` for
    any `./factory-assets/*` subpath export (published types were missing) and
    `typecheck` no longer covered the tree. Renamed the path alias + includes.
  - **`skaile run <flow>`** now honours project-local flow overrides in the flat
    `ai-assets/flows/` layout (and `.flow.json`), not just the domain-nested layout —
    the hand-rolled walk is replaced by the layout-tolerant `findFlowFile`, which
    gained a `priorityRoots` parameter.
  - **Factory built-in source** now resolves through the same `SKAILE_FACTORY_ASSETS_DIR`
    (legacy `SKAILE_BASE_ASSETS_DIR`) env override that the runtime uses, so `search`/`add`
    and `serve` see the same assets in containers (new `resolveFactoryAssetsRootOrNull`).
  - **Duplicate factory source** is now deduped against user-declared sources by a
    normalized URL (scheme / `.git` / trailing-slash / `git@` / case), not exact string.
  - `assetSearchDirs` no longer treats a `tests/` directory as a content domain.
  - `resolveWorkspacesPackageRoot()` is memoized — it sits on the CLI completion hot path.

- [#96](https://github.com/skaile-ai/workspaces/pull/96) [`d755e79`](https://github.com/skaile-ai/workspaces/commit/d755e790f63b34a9bec6a70fc3017b9f66d34b3c) Thanks [@mortegro](https://github.com/mortegro)! - Fix `AssetManager.info(ref)` to resolve publisher-scoped refs when a source's URL
  slug differs from its manifest publisher. `info` guarded on the repo-map key
  (= URL slug) instead of the scanned manifest publisher, so it returned `not found`
  for any `kind:@<publisher>/name` ref against such a source — even though `add` /
  `search` resolved the same asset. It now matches on the entry's real publisher,
  mirroring `add`'s resolution.

- [#94](https://github.com/skaile-ai/workspaces/pull/94) [`8b31c0b`](https://github.com/skaile-ai/workspaces/commit/8b31c0bf85f920f56936e92bf341ce3992fc9bd9) Thanks [@mortegro](https://github.com/mortegro)! - deploy(container): share one log stream between readiness and `logs()`.

  The docker/podman deploy target now pumps a single `<bin> logs --follow` into one
  `LineBuffer` at `create()`/`restore()` time. Both `waitReady` (ready-marker
  watch) and `handle.logs()` (history replay + tail) read from that shared buffer,
  so the log stream is decoded and split once instead of being spawned and parsed
  twice on startup. The stream is torn down on `stop()`.

## 0.25.0

### Minor Changes

- [#89](https://github.com/skaile-ai/workspaces/pull/89) [`1b9c501`](https://github.com/skaile-ai/workspaces/commit/1b9c501380f91d0a76c6c806035fbd5e8b228a05) Thanks [@peteralbert](https://github.com/peteralbert)! - Make recipe/stdio MCP servers (e.g. excel, ppt) work on **any** bridge by moving
  external MCP spawning + connection out of per-driver SDK delegation and into the
  runner.

  Previously external (`mcp_servers:` / recipe-backed) MCP servers were handed to
  the claude-sdk driver as `{type:"stdio"}` entries on the claude-agent-sdk's
  `mcpServers` query option, so the **SDK** owned spawning the subprocess. That
  coupled external MCP support to one driver (only claude-sdk consumes
  `mcpServers`) and to the SDK version (external stdio servers via the query option
  require the 0.3.x line).

  Now, when a session has a v2 capability registry (serve mode), the **runner**
  spawns each external MCP subprocess itself via `@modelcontextprotocol/sdk`'s
  `Client` + transport, runs `initialize` + `tools/list`, and registers every tool
  into the capability registry as an `mcp`-origin capability named
  `mcp__<server>__<tool>`. Invocations dispatch through the normal capability path,
  so every driver reaches the tools through the single surface they consume — no
  dependence on the claude-agent-sdk `mcpServers` option. The subprocess is owned
  by the runner: spawned after recipe resolution, kept alive for the session, and
  killed on session end. A spawn/connect failure for one server is logged and
  skipped — it never aborts session startup.

  In-process MCP servers (`skaile-connectors` / `skaile-workspace`) stay on their
  existing claude-sdk path. When no capability registry is present (CLI / flow),
  the legacy claude-sdk-only SDK delegation is retained as a fallback. The stdio
  subprocess env now merges a safe base (PATH/HOME/TMPDIR via the MCP SDK's
  `getDefaultEnvironment()`) under the asset's declared, recipe-substituted env.

- [#82](https://github.com/skaile-ai/workspaces/pull/82) [`a4f0a09`](https://github.com/skaile-ai/workspaces/commit/a4f0a099ee6803fdb02844115c8326f8b20ce885) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Stage materialized skills into the driver's native skills dir so subscribed store skills become invokable in a session.

- [#86](https://github.com/skaile-ai/workspaces/pull/86) [`cc6821e`](https://github.com/skaile-ai/workspaces/commit/cc6821ee0077bbbd372b4ebe7802759d6a75fe1b) Thanks [@mortegro](https://github.com/mortegro)! - Enforce and validate canonical asset names. `parseAssetRef` now rejects
  malformed names (spaces, uppercase, underscores, …) with a clear
  `invalid asset name "…"` error instead of the misleading "publisher required"
  message when a publisher scope is present. Adds `isValidAssetName` /
  `ASSET_NAME_HINT` to `@skaile/workspaces/core`, and extends `skaile validate
[path]` to check every manifest's canonical identity (skill/agent `name`, flow
  `id`) and every `skaile.yaml` dependency/override ref + `assets[]` identity
  across a repository tree.

### Patch Changes

- [#85](https://github.com/skaile-ai/workspaces/pull/85) [`7fe0d55`](https://github.com/skaile-ai/workspaces/commit/7fe0d5534c502ef3ab2f3ccec9cc255a1033dbaf) Thanks [@mortegro](https://github.com/mortegro)! - Fix the pointer-only install path to speak the canonical `kind:@publisher/name#version`
  ref grammar. `installFromManifest` and `skaile install <ref>` were still parsing the
  legacy trailing-`@version` shape (removed from `core` in the prior breaking change),
  so they rejected the canonical `#version` refs the store emits and could not install a
  manifest fetched from the Catalog. Both now parse via `parseAssetRef`; the install
  identity check compares parsed refs (kind/publisher/name/pin). The `@version` shape
  stays rejected.

## 0.24.0

### Minor Changes

- [#80](https://github.com/skaile-ai/workspaces/pull/80) [`cedf7b4`](https://github.com/skaile-ai/workspaces/commit/cedf7b4a97cb23f7aa40054b9a694acf5c5aaf93) Thanks [@peteralbert](https://github.com/peteralbert)! - Decouple `session_init_ack` from rclone-backed filesystem-mount readiness.

  The runner now emits `session_init_ack` as soon as the driver and the
  fast/critical (eager) connectors are ready, and brings slow rclone-backed
  filesystem mounts (sharepoint, webdav, googledrive) up in the background.
  A slow or wedged remote degrades to "workspace still syncing" instead of
  blowing the platform's session-init ack deadline and triggering the
  container restart loop.

  - `FilesystemFace` gains an optional `deferMount` marker; the rclone-backed
    connectors set it. `ConnectorManager.connectAll` accepts `{ deferMounts }`,
    connecting eager connectors synchronously (preserving `failOnReadWriteError`
    for them) and driving deferred mounts on a tracked background task that
    reports progress via the existing `onStatusChange` (`connector_status`)
    channel. Deferred mount paths are pre-registered so the system-prompt mount
    line renders immediately.
  - `ConnectContext` gains an optional `abortSignal`, aborted on
    `disconnectAll()` so in-flight background mounts are cancelled/awaited and no
    rclone process leaks.
  - Serve mode opts in via `AgentSessionConfig.deferFilesystemMounts`; CLI/flow
    paths keep mounting eagerly.

### Patch Changes

- [#79](https://github.com/skaile-ai/workspaces/pull/79) [`412169a`](https://github.com/skaile-ai/workspaces/commit/412169a28591f47a5cb247a7f5ed9e19e6056ffe) Thanks [@P1umPudding](https://github.com/P1umPudding)! - Fix the `xstate-store` connector silently dropping the `delete` op. `_executeOp` handled `set`/`merge`/`reset` but had no `delete` case, so a host `connector_mutate {op:"delete", key}` hit the `default` branch and threw (swallowed upstream) — the key was never removed and no `state_changed` broadcast fired. This left stale shared-state entries stuck (e.g. a "X is typing…" indicator that never cleared, since the stop maps to a delete op). Added the `delete` case (mirrors the tool-face `_delete`: mutates the store, records history, emits the change) plus a matching `describeOperations` entry.

## 0.23.0

### Minor Changes

- [#77](https://github.com/skaile-ai/workspaces/pull/77) [`9e6fbdf`](https://github.com/skaile-ai/workspaces/commit/9e6fbdfd73705ac734da9eb195096394cbd51122) Thanks [@peteralbert](https://github.com/peteralbert)! - fix(connectors): stabilize-before-upload for the rclone-backed connectors (sharepoint/webdav/googledrive)

  rclone repeatedly failed to upload a file the agent was actively rewriting: the
  upload started mid-write, the source grew during transfer, and rclone aborted
  with `corrupted on transfer: sizes differ` and retried indefinitely — invisible
  to sibling sessions that only share state through the remote. Three guards in
  `RcloneProcessManager` address the race:

  - `--vfs-write-back` is now a connector option (`options.vfsWriteBack`, plumbed
    via `RcloneSpawnConfig.vfsWriteBack`) with its default raised from a
    hard-coded `5s` to `30s`, so a rapidly-rewritten file settles before upload.
  - `flush()` now waits for the VFS cache dir to go quiescent (no write within a
    stability window, bounded by a timeout) before issuing `/vfs/refresh`, so a
    close/hibernate-triggered flush never snapshots a half-written file.
  - A sustained upload failure (the same object failing `K=3` times in a row) now
    surfaces an error-level `sync_status`/`phase:error` log through the
    `<driver>+rclone` subkind instead of retrying silently forever.

- [`0f4dcf2`](https://github.com/skaile-ai/workspaces/commit/0f4dcf28878590dee416dd57084e374c35d85d8c) Thanks [@mortegro](https://github.com/mortegro)! - feat(core): migrate asset-ref grammar to npm-style scope `kind:@publisher/name[#version]`

  The canonical asset-ref grammar changes from `kind:name@<publisher>[#pin]` to
  `kind:@<publisher>/name[#version]` — `@` is now exclusively the scope sigil and
  `#` the version sigil, so `@publisher/name` reads as an npm scoped package. The
  lock key is now a fully-pinned ref of this exact shape
  (`kind:@<publisher>/name#version`), collapsing the graph ref, lock key, and store
  install coordinate into one grammar.

  **Lock files must be regenerated.** The lock `schema_version` is bumped `2 → 3`.
  An older lock is rejected with a clear regenerate error before any key is parsed:

  ```
  rm skaile.lock.yaml && skaile install
  ```

  **Deprecation window.** Legacy `kind:name@<publisher>` refs and a trailing
  `@version` (pure-npm shape) still parse for this release, each emitting a one-time
  deprecation warning. Both are removed in the next release, after which they throw
  with a `migrate-skaile-manifest` hint. Run the `migrate-skaile-manifest` skill to
  rewrite authored manifests now.

### Patch Changes

- [#76](https://github.com/skaile-ai/workspaces/pull/76) [`3d93dba`](https://github.com/skaile-ai/workspaces/commit/3d93dba7e84c980bbb1d4cefab3cd8cf2647fc92) Thanks [@mortegro](https://github.com/mortegro)! - fix(cli): stop long asset names overflowing the `skaile manage` row

  `renderAssetRow` padded the name to 30 columns but never truncated it, so a
  name longer than 30 chars pushed the badge/description past the row width (the
  desc budget assumes name is exactly 30 wide). The Sources/Libraries rows had the
  same `padEnd`-only bug on their name columns. Added a `fitWidth` helper
  (truncate-with-`…` then pad) and used it for every fixed-width name column.

- [#74](https://github.com/skaile-ai/workspaces/pull/74) [`f514c6b`](https://github.com/skaile-ai/workspaces/commit/f514c6b870a2555e41bb9641826fd27d715d3cd0) Thanks [@mortegro](https://github.com/mortegro)! - fix(core): align manage-TUI listing with `add` resolution

  Two divergences let `skaile manage` offer assets that `add` then failed to
  resolve with `Asset not found`:

  - `scanRepo`/`scanDirectory` stamped `CatalogEntry.publisher` with the source's
    config key (slug), while `resolveAll` keys the provenance index by the
    source's declared `skaile.yaml` `publisher` (GitHub-org fallback). When the
    two differ, the TUI emitted `name@<slug>` refs the resolver couldn't find.
    `scanRepo` now derives the publisher the same way the walker does.
  - The provenance fallback (`walkFilenameConvention`, used when a source has no
    `assets[]`) only scanned top-level `<kind-plural>/<name>/` dirs, while the TUI
    scanner (`scanDirectory`) recurses the whole tree. A nested asset
    (e.g. `flows/x/x.bundle.yaml`) was listed but unresolvable. The fallback now
    reuses `scanDirectory`, so both paths discover the same assets by
    construction.

- [#78](https://github.com/skaile-ai/workspaces/pull/78) [`cd5ecf9`](https://github.com/skaile-ai/workspaces/commit/cd5ecf95d3f2d7bf8f71cc9afe333c7b184afe24) Thanks [@peteralbert](https://github.com/peteralbert)! - fix(connectors): add SharePoint `ignoreSizeCheck` option for server-side byte mutation

  SharePoint Online normalises some text uploads server-side (observed: `*.html`
  with `LF` rewritten to `CRLF`), so the stored object ends up a different byte
  size and checksum than the local source. rclone's post-transfer verification
  correctly flags this as `corrupted on transfer: sizes differ`, deletes the
  just-uploaded object ("Removing failed copy"), and retries indefinitely — so
  the affected file never durably lands on the remote and sibling sessions in the
  same project never see it. Sibling `*.md` / `*.mjs` uploads are unaffected
  because they aren't normalised.

  Adds an off-by-default `options.ignoreSizeCheck` to the SharePoint connector.
  When `true`, it appends `--ignore-size --ignore-checksum` to that mount's
  `rclone mount` invocation (both flags are needed — a byte change shifts size
  _and_ hash), so rclone accepts the upload despite the drift. This trades
  integrity verification for sync-ability and is scoped to the SharePoint driver
  only — it is not a global rclone flag and is not exposed by the other
  rclone-backed connectors. The rclone arg construction is factored into the
  exported pure helper `_buildRcloneMountArgs` so the conditional flags are
  unit-tested without spawning rclone.

## 0.22.0

### Minor Changes

- [#70](https://github.com/skaile-ai/workspaces/pull/70) [`f44674b`](https://github.com/skaile-ai/workspaces/commit/f44674bd72f9231f1d429152ba683ae1e4dc20df) Thanks [@Frozen666](https://github.com/Frozen666)! - Add `network: NetworkPolicy` field on `AgentPermissions` (modes `open` /
  `off` / `allowlist`). Consumed by the platform's per-session egress
  firewall; the codec is permissive (round-trips unknown shapes) and emits
  warning diagnostics for unrecognized `mode` values and for `allowlist`
  entries set under non-`allowlist` modes.

- [`cd5b3b3`](https://github.com/skaile-ai/workspaces/commit/cd5b3b39ed6e65f0124d5cda81fc791ddd1c25e5) Thanks [@mortegro](https://github.com/mortegro)! - Asset display: regroup the `skaile manage` TUI as **publisher → repo → asset**.

  The derived `domain` axis (first-level directory of a scanned repo) is replaced
  by `repo` — the real `"<org>/<repo>"` GitHub coordinate. `category` is demoted
  out of the tree to a row badge.

  - `plugins` — `CatalogAsset` gains optional `repo?`.
  - `core` — `CatalogEntry` drops `domain`, gains `repo?` and `category?`.
    `entryFromRaw` reads `repo` with a one-release legacy-`domain` read-fallback
    (stale `catalog.yaml` caches self-heal on next write). `scanRepo` derives
    `repo` once from the scanned root's git remote (`repoSlugFromGitRemote`) and
    stamps it onto every entry; `scanDirectory` no longer emits a per-asset
    `domain`.
  - `library` — `RemoteCatalogSource` threads the store's `repo` through.
  - `cli` — `manage` regroups to publisher → repo → asset with category badges;
    the `asset-feeds` store mapping now carries `publisher`/`repo`/`category`
    (fixing the bug that mis-shelved every remote asset under `other`).

  Local libraries with no git remote (and non-GitHub sources) have no `repo`;
  their assets group directly under the publisher.

- [#71](https://github.com/skaile-ai/workspaces/pull/71) [`fb32a60`](https://github.com/skaile-ai/workspaces/commit/fb32a6028ce1c0639c3339e8561ba72dcd24388f) Thanks [@mortegro](https://github.com/mortegro)! - Deploy contract: add a generic per-session injection channel and a built-in
  `nix` target, so a host (e.g. skaile-platform) can drive every deploy target
  uniformly without forking the decision in its own code.

  - `DeployContext` gains optional `mounts` / `labels` / `resources`
    (`@skaile/workspaces/plugin-registry`, with new `DeployMount` / `DeployResources`
    types). Container built-ins (`docker` / `podman`) map them to `-v` / `--label`
    / `--cpus` / `--memory` / `--pids-limit`; the process built-ins ignore mounts.
    Target-specific policy (apparmor, FUSE, fleet binds, …) stays in each target's
    own `configSchema` — it never enters this shared context.
  - `local` now also merges `ctx.env` into the spawned process env (`config.env`
    still wins).
  - New built-in `nixDeployTarget` (id `nix`) runs `skaile serve` inside a
    `nix shell` over `ws://127.0.0.1:<port>`; registered by
    `registerBuiltinDeployTargets()`. Exports `nixDeployTarget` + the pure
    `buildNixArgs()` from `@skaile/workspaces/deploy`. Generic on purpose
    (`flakeRef` + `stackAttr` only).

- [`ce3f33f`](https://github.com/skaile-ai/workspaces/commit/ce3f33f5b85f81d1c80689a99a8e63cf0c2afc07) Thanks [@mortegro](https://github.com/mortegro)! - Fix deploy-handle readiness timeout + dedupe deploy-target helpers.

  - **Fix:** `DeployHandle.waitReady(timeoutMs)` silently ignored its `timeoutMs`
    argument in the fly / k8s / vercel-sandbox targets, always using a hard-coded
    internal deadline. The caller's timeout is now honored.
  - **Fix (`@skaile/provider-fly`):** `deleteMachine` swallowed _all_ errors while
    only intending to ignore a 404, so `stop()` could falsely report success on a
    still-running (still-billing) machine. Non-404 errors now propagate.
  - **Added (`@skaile/workspaces/plugin-registry`):** shared deploy helpers —
    `buildStrategySchema` / `BuildStrategy`, abort-aware `sleep`, generic
    `pollUntil`, and the `makeDeployHandle` factory — replacing the per-provider
    copies of the sleep loop, handle wrapper, and build-strategy enum.

- [#65](https://github.com/skaile-ai/workspaces/pull/65) [`2af2e52`](https://github.com/skaile-ai/workspaces/commit/2af2e525588648e45a14fb8acdfde72f1ebb57e8) Thanks [@mortegro](https://github.com/mortegro)! - Add `listDeployTargets()` / `getDeployTarget()` to
  `@skaile/workspaces/plugin-registry`, the symmetric counterparts to
  `listDrivers()` / `listConnectors()` so UIs can enumerate all three plugin kinds
  uniformly. Both default to the process-wide `pluginRegistry`;
  `listDeployTargets()` returns lightweight `{ id, displayName }` rows and
  `getDeployTarget(id)` returns the full target without the override/yaml/default
  precedence `resolveDeployTarget` applies.

  Docs (`docs/deploy/`): documented the container-target image lifecycle — that
  `buildStrategy: "local"` is bring-your-own-image (`create()` never builds), how
  `pullPolicy` interacts with a locally-built vs. registry-published image, that
  `workspace.container.{packages,agent_clis,stack,nix}` are image-build inputs
  distinct from the deploy `config.image` (with skaile-platform's
  `skaile-vm-agent:thin` / Nix-stack model as the reference), and that the
  in-process runtime is deliberately out of scope for the deploy abstraction
  (no `wsUrl`).

- [#67](https://github.com/skaile-ai/workspaces/pull/67) [`c67a381`](https://github.com/skaile-ai/workspaces/commit/c67a381f1029bec547531e238d235636eef16740) Thanks [@mortegro](https://github.com/mortegro)! - Canonical-identity manifest architecture (BREAKING).

  Schema:

  - `skaile.yaml` top-level keys `repositories:` and `ai_resources:` are removed
    and now throw a parse error pointing at `docs/concepts/manifest-schema.md` and
    the `migrate-skaile-manifest` skill.
  - New publication half: `publisher`, `version`, `assets[]`.
  - New consumption half: collapsed `sources:[{url, pin?}]` (no `name`),
    `stores:[{url}]`, `dependencies:[<kind>:<name>@<publisher>[#pin]]`,
    `overrides:[{ref, source, reason}]` with required `reason:`.

  Dep ref grammar:

  - Canonical form `kind:name@<publisher>[#pin]`. `publisher` is required and
    GitHub-shaped. Pins accept SemVer constraints (^, ~, x), exact SemVer, a
    40-char SHA, or absent. Floating refs (main/latest/HEAD) throw.

  Resolution:

  - The resolver collects candidates from every source clone (via the new
    provenance-index walker) and optionally every store. Divergent sha256 at the
    same (publisher, kind, name, version) is a hard error
    (`CanonicalRefConflictError`) with a dep-chain walk. `overrides[]` pin a
    chosen source and flag `override_applied: true` on the lock entry. A cheap
    `catalog.getCanonicalDigest` probe cross-checks source vs store at the same
    version. `resolveAll` is now async.

  Lock file:

  - Schema v2. Keys are canonical refs `<publisher>/<kind>:<name>@<version>`;
    values carry `sha256`, `source.{url, commit}`, `files[]`, `override_applied`.
    Top-level `sources:` records every contributing URL+commit. Old v1 lockfiles
    must be deleted and regenerated; the reader emits a clear error.

  Types:

  - `AssetRef.repository` → `AssetRef.publisher` (and `CatalogEntry.publisher`).
  - `RepositoryDeclaration` → `SourceDeclaration`.
  - `LockEntry` reshaped; new `LockSourceEntry`, `LockFileEntry`,
    `StoreEntry`, `AssetEntry`, `OverrideEntry`.
  - `RemoteCatalogSource.getCanonicalDigest(ref)` added.

  Migration:

  - No `skaile manifest migrate` CLI command. The transform is performed by the
    `migrate-skaile-manifest` skill (lives in `ai-assets`).

- [#53](https://github.com/skaile-ai/workspaces/pull/53) [`4e3c2b2`](https://github.com/skaile-ai/workspaces/commit/4e3c2b27ed6a004e7b9c3d60b0ae3754a8192eb5) Thanks [@peteralbert](https://github.com/peteralbert)! - Support the BYO-flake recipe form on `AssetRecipe`.

  - `AssetRecipe` gains optional `flake` (the asset's own flake: `"."` or a flake URL) and
    `publisher` fields; `attr` is now optional and defaults to `"default"` for the flake form
    (the legacy `{ attr }` platform-flake form is unchanged and still requires `attr`).
  - New `validateAssetRecipeFlake` validates the flake source shape (`"."`, `github:`,
    `git+https://`, `git+ssh://`, `path:`; rejects bare paths, traversal, unknown schemes, and
    shell metacharacters). Exported alongside `DEFAULT_RECIPE_ATTR`.
  - `mcpDeclFromCatalogEntry` carries `flake`/`publisher` onto the decl; an invalid flake drops
    the whole recipe (no silent fall back to a platform-flake attr).
  - The runner resolves a flake-sourced recipe by `mcps.<id>` (the recipe id) rather than `attr`,
    since BYO-flake recipes all build `#default` and `attr` is not a unique map key.

- [`edaca8f`](https://github.com/skaile-ai/workspaces/commit/edaca8f351dd4d044bc2034c8f10b394a8ab9f43) Thanks [@mortegro](https://github.com/mortegro)! - Restore transitive bundle-member expansion in the canonical-identity resolver.

  The resolver rewrite dropped the requires/dependencies recursion, so adding a
  bundle resolved only the bundle marker and deployed **zero** members. The
  provenance walker now populates `ProvenanceCandidate.deps` for bundle
  candidates (parsed from the `.bundle.yaml` / `BUNDLE.md` `requires` +
  `dependencies`), and `resolveAll` recurses into them — so a tier bundle pulls
  its full inheritance chain and every leaf skill (arbitrary depth, deduped via
  the existing `seen` set). Bare transitive refs inherit the parent bundle's
  publisher.

  - `core/manifest` — new `bundleDepRefs(filePath)` returns a bundle's raw
    canonical dep refs (publisher/pin preserved; unlike `parseRequires`).
  - `core/walker` — `ProvenanceCandidate` gains `deps?: string[]`, populated for
    bundles in both the manifest and filename-convention walk paths.
  - `core/repo-manager` — `resolveAll.visit` recurses into `chosen.deps`,
    qualifying bare refs with the parent publisher.

  Non-bundle candidates are unaffected (no `deps`), so legacy skill frontmatter
  `requires` are not auto-expanded.

- [`e219bf1`](https://github.com/skaile-ai/workspaces/commit/e219bf1a483538b2e6a8538ab87bda8c5063ad21) Thanks [@mortegro](https://github.com/mortegro)! - CLI: `skaile source add` now accepts GitHub shorthand — a bare `<name>`
  resolves to `git@github.com:skaile-ai/<name>.git` and `<owner>/<repo>` to
  `git@github.com:<owner>/<repo>.git`; full URLs and scp-style specs pass through
  unchanged.

  CLI: fixed `skaile npx skills add <url> --skill <name>` (the npx compatibility
  shim) under the canonical-identity model. It now clones into the canonical
  sources cache (`~/.skaile/sources/<slug>`), registers the source in the
  project's `skaile.yaml` `sources[]`, builds a publisher-qualified ref
  (`skill:<name>@<publisher>`, publisher from the source's `skaile.yaml` or its
  GitHub org), and maps a requested SKILL.md `name:` to the directory the
  canonical resolver keys by.

- [#54](https://github.com/skaile-ai/workspaces/pull/54) [`7bdaf11`](https://github.com/skaile-ai/workspaces/commit/7bdaf115c251136288a94a7f880b3a0f52145be8) Thanks [@mortegro](https://github.com/mortegro)! - Unified plugin registry + deploy providers.

  **Breaking**

  - Removed the external `registerDriver` / `registerConnector` APIs. Register a
    typed target via `pluginRegistry.register(kind, target)` instead
    (`@skaile/workspaces/plugin-registry`). `createDriver` / `getConnector` /
    `listConnectors` survive as resolve-only wrappers.
  - Dropped `ioredis` and `yjs` runtime deps and the bundled `redis` / `yjs`
    connectors. Install `@skaile/connector-redis` / `@skaile/connector-yjs` via
    `skaile plugin install`.
  - Renamed the coding-agent adapter command `skaile plugin` →
    `skaile integration` (install/uninstall/enable/disable/status/hook). The
    `skaile plugin` name now manages registry plugins.

  **Added**

  - `@skaile/workspaces/plugin-registry` — one process-wide `pluginRegistry` with
    three kinds (driver / connector / deployTarget), the Target contracts,
    `DeployHandle` / `DeployContext`, and `resolveDeployTarget` (override > yaml >
    default precedence).
  - `@skaile/workspaces/deploy` — built-in `local` / `docker` / `podman` deploy
    targets (loopback-only), `registerBuiltinDeployTargets()`, and the
    `.skaile/deploy/handle.json` store.
  - `@skaile/workspaces/connectors-shared` — minimal public connector API for
    extracted connector plugins.
  - `skaile.yaml` gains `plugins:` (project-local plugin store) and `deploy:`
    blocks; `skaile.lock.yaml` gains a `plugins:` slice.
  - CLI: `skaile deploy [up|down|status|logs]` and `skaile plugin
[install|remove|list]`.
  - `DRIVER_CATALOG` → `BUILTIN_DRIVER_CATALOG` (old name kept as an alias); new
    `BUILTIN_CONNECTOR_CATALOG`.

  See `docs/migration-v2-to-v3.md`.
  </content>

- [#71](https://github.com/skaile-ai/workspaces/pull/71) [`fb32a60`](https://github.com/skaile-ai/workspaces/commit/fb32a6028ce1c0639c3339e8561ba72dcd24388f) Thanks [@mortegro](https://github.com/mortegro)! - WebSocket transport: enforce a bearer auth token end-to-end so a deploy target's
  issued `wsAuth` actually gates the socket.

  - `WebSocketServerTransport` accepts `authToken`. When set, every upgrade must
    present the matching token via the `skaile-bearer.<base64url>` subprotocol or
    the handshake is rejected with HTTP 401 (before any command handler runs).
    When unset, behaviour is unchanged (back-compatible).
  - `WebSocketClientTransport` accepts `auth`; sends it as the WS subprotocol
    (the one handshake field a browser client can populate).
  - `startAgentServer` threads `opts.authToken ?? SKAILE_WS_AUTH_TOKEN` into the
    default server, so `skaile serve` enforces the token from the environment.
  - New `@skaile/workspaces/transport` exports: `encodeBearerSubprotocol`,
    `decodeBearerFromHeader`, `tokensEqual`, `BEARER_SUBPROTOCOL_PREFIX`.
  - Robustness fix: `WebSocketClientTransport.connect()` now rejects when the
    socket closes or errors before opening (a rejected auth upgrade, or a refused
    port) instead of hanging until the connect timeout.

### Patch Changes

- [#60](https://github.com/skaile-ai/workspaces/pull/60) [`69f9924`](https://github.com/skaile-ai/workspaces/commit/69f99243cff137da1f835777e298e0779dec45e2) Thanks [@peteralbert](https://github.com/peteralbert)! - Emit sidecar `.d.ts` files next to each tsup `.js` output so consumers and
  tools that look for a declaration file adjacent to the `.js` (classic node
  resolution, some bundlers, IDE quick-lookups, ad-hoc `grep` audits) find one.

  Previously, tsup emitted JS at `dist/<entry>.js` (e.g.
  `dist/core/workspace-config.js`) while `tsc` emitted `.d.ts` at the
  source-preserving path `dist/<src>/<entry>.d.ts` (e.g.
  `dist/core/src/workspace-config.d.ts`). The package.json `exports` map
  bridged these with separate `types` and `import` conditions — correct for
  node16/nodenext/bundler resolvers, but invisible to anything looking
  adjacent to the `.js`. A new post-build step (`build:dts-sidecars`) writes a
  thin re-export shim at the tsup output path that points at the canonical
  tsc-emitted declaration, eliminating the apparent stale-types mismatch
  reported on `@skaile/workspaces/core/workspace-config`.

- [#66](https://github.com/skaile-ai/workspaces/pull/66) [`90a2c61`](https://github.com/skaile-ai/workspaces/commit/90a2c61564b4dd91ff54a96d3dcff213c35e465f) Thanks [@mortegro](https://github.com/mortegro)! - deploy(container): make `isRunning()` async so the docker/podman `inspect` round-trip stays off the event loop. Previously `health()`/`restore()` called a synchronous `portableSpawnSync` inspect, freezing log pumps and other connections on each status poll.

- [#73](https://github.com/skaile-ai/workspaces/pull/73) [`6aaac33`](https://github.com/skaile-ai/workspaces/commit/6aaac33e1ccf98c846c51b6ae57b305ebb558cf8) Thanks [@mortegro](https://github.com/mortegro)! - Fix `skaile manage` TUI row overflow when assets carry a `category`.

  The category badge (`[category] `) sits between the asset name and the
  description but its visible width was never subtracted from the description's
  truncation budget, so categorized rows ran past the terminal width and wrapped.
  The desc budget now accounts for the badge width.

- [#68](https://github.com/skaile-ai/workspaces/pull/68) [`4073406`](https://github.com/skaile-ai/workspaces/commit/407340687ce038c9ae8a71cc8b80e237545a4db9) Thanks [@Frozen666](https://github.com/Frozen666)! - fix(base-assets/git): recurse into submodules on clone and pull. Git mounts cloned without `--recurse-submodules` and never ran `submodule update`, leaving submodule directories empty or stale on upstream pointer bumps. A `syncSubmodules()` helper now runs after every ref-changing operation (clone, bootstrap, session-branch checkout, sync pull, merge-on-close, periodic auto-pull/rebase); it no-ops when `.gitmodules` is absent and swallows submodule failures so a broken submodule remote can't abort the parent mount.

- [#64](https://github.com/skaile-ai/workspaces/pull/64) [`f1b14f3`](https://github.com/skaile-ai/workspaces/commit/f1b14f3f9bed870efe6f8c7de2819cfbeeb444a2) Thanks [@mortegro](https://github.com/mortegro)! - fix(sdk): InProcessTransport now tracks real readiness and never drops commands

  `InProcessTransport.connected` previously returned a hard-coded `true`, so
  `LocalRuntime.isRunning` reported `true` even before `start()`. It now reflects
  whether a command handler is actually registered.

  `inject()` also silently dropped commands sent before the runner wired its
  `onCommand` handler (e.g. a `debug`/`prompt` issued during the ~50ms `start()`
  window, or against a lazily-started runtime), which made debug queries hang
  until their own timeout. Such commands are now buffered and flushed to the
  first handler, so none are lost.

  `LocalRuntime` gains `whenReady()`, a promise resolved once `startAgentServer`
  returns, so consumers can await wiring before issuing reply-expecting commands.

- [`03d1b69`](https://github.com/skaile-ai/workspaces/commit/03d1b6986fed2b586aede659c702575040cecfa3) Thanks [@mortegro](https://github.com/mortegro)! - Fix `skaile manage` TUI emitting publisher-less asset refs that crash on apply.

  `assetRefOf` built `kind:name`, dropping the entry's publisher. Under the
  canonical-identity schema `parseAssetRef` requires a publisher, so applying a
  selection called `AssetManager.add("bundle:complex-app")` and threw
  _"publisher required in asset ref"_. It now emits the canonical
  `kind:name@<publisher>` via `assetRefToStr`. Entries with no publisher have no
  canonical identity and are no longer selectable (returns `null`) instead of
  producing a ref that throws; `repoAssetRefs`/`sourceAssetRefs` filter those out.

- [#60](https://github.com/skaile-ai/workspaces/pull/60) [`69f9924`](https://github.com/skaile-ai/workspaces/commit/69f99243cff137da1f835777e298e0779dec45e2) Thanks [@peteralbert](https://github.com/peteralbert)! - Add `unregister(kind, id)` to `PluginRegistry` so tests can swap a built-in
  target (e.g. the real `local` / `git` connector) for a fake within the
  process-wide singleton. Production callers should rarely reach for it —
  built-ins and loaded plugins register once at startup and live for the
  process — but the hook is needed for test isolation across describe blocks
  that share the registry.

- [`cd3bee2`](https://github.com/skaile-ai/workspaces/commit/cd3bee27bd6e182b4d8f8d08f1816820edd2b0a7) Thanks [@mortegro](https://github.com/mortegro)! - Auto-clone url-backed source caches on miss. `ensureRepo` now clones a remote
  source into its managed `~/.skaile/sources/<slug>` cache dir when the clone is
  absent, instead of throwing `Local repository path not found` — so `skaile
install` works without a pre-populated clone. The source pin is no longer
  mis-encoded as a clone `--branch` (tag/SHA pins now reach `checkoutPin`), and a
  clone/auth failure surfaces as `missing: ["source:<slug> (<reason>)"]` rather
  than a bare slug.

## 0.22.0-beta.2

### Minor Changes

- [`cd5b3b3`](https://github.com/skaile-ai/workspaces/commit/cd5b3b39ed6e65f0124d5cda81fc791ddd1c25e5) Thanks [@mortegro](https://github.com/mortegro)! - Asset display: regroup the `skaile manage` TUI as **publisher → repo → asset**.

  The derived `domain` axis (first-level directory of a scanned repo) is replaced
  by `repo` — the real `"<org>/<repo>"` GitHub coordinate. `category` is demoted
  out of the tree to a row badge.

  - `plugins` — `CatalogAsset` gains optional `repo?`.
  - `core` — `CatalogEntry` drops `domain`, gains `repo?` and `category?`.
    `entryFromRaw` reads `repo` with a one-release legacy-`domain` read-fallback
    (stale `catalog.yaml` caches self-heal on next write). `scanRepo` derives
    `repo` once from the scanned root's git remote (`repoSlugFromGitRemote`) and
    stamps it onto every entry; `scanDirectory` no longer emits a per-asset
    `domain`.
  - `library` — `RemoteCatalogSource` threads the store's `repo` through.
  - `cli` — `manage` regroups to publisher → repo → asset with category badges;
    the `asset-feeds` store mapping now carries `publisher`/`repo`/`category`
    (fixing the bug that mis-shelved every remote asset under `other`).

  Local libraries with no git remote (and non-GitHub sources) have no `repo`;
  their assets group directly under the publisher.

- [#71](https://github.com/skaile-ai/workspaces/pull/71) [`fb32a60`](https://github.com/skaile-ai/workspaces/commit/fb32a6028ce1c0639c3339e8561ba72dcd24388f) Thanks [@mortegro](https://github.com/mortegro)! - Deploy contract: add a generic per-session injection channel and a built-in
  `nix` target, so a host (e.g. skaile-platform) can drive every deploy target
  uniformly without forking the decision in its own code.

  - `DeployContext` gains optional `mounts` / `labels` / `resources`
    (`@skaile/workspaces/plugin-registry`, with new `DeployMount` / `DeployResources`
    types). Container built-ins (`docker` / `podman`) map them to `-v` / `--label`
    / `--cpus` / `--memory` / `--pids-limit`; the process built-ins ignore mounts.
    Target-specific policy (apparmor, FUSE, fleet binds, …) stays in each target's
    own `configSchema` — it never enters this shared context.
  - `local` now also merges `ctx.env` into the spawned process env (`config.env`
    still wins).
  - New built-in `nixDeployTarget` (id `nix`) runs `skaile serve` inside a
    `nix shell` over `ws://127.0.0.1:<port>`; registered by
    `registerBuiltinDeployTargets()`. Exports `nixDeployTarget` + the pure
    `buildNixArgs()` from `@skaile/workspaces/deploy`. Generic on purpose
    (`flakeRef` + `stackAttr` only).

- [`e219bf1`](https://github.com/skaile-ai/workspaces/commit/e219bf1a483538b2e6a8538ab87bda8c5063ad21) Thanks [@mortegro](https://github.com/mortegro)! - CLI: `skaile source add` now accepts GitHub shorthand — a bare `<name>`
  resolves to `git@github.com:skaile-ai/<name>.git` and `<owner>/<repo>` to
  `git@github.com:<owner>/<repo>.git`; full URLs and scp-style specs pass through
  unchanged.

  CLI: fixed `skaile npx skills add <url> --skill <name>` (the npx compatibility
  shim) under the canonical-identity model. It now clones into the canonical
  sources cache (`~/.skaile/sources/<slug>`), registers the source in the
  project's `skaile.yaml` `sources[]`, builds a publisher-qualified ref
  (`skill:<name>@<publisher>`, publisher from the source's `skaile.yaml` or its
  GitHub org), and maps a requested SKILL.md `name:` to the directory the
  canonical resolver keys by.

- [#71](https://github.com/skaile-ai/workspaces/pull/71) [`fb32a60`](https://github.com/skaile-ai/workspaces/commit/fb32a6028ce1c0639c3339e8561ba72dcd24388f) Thanks [@mortegro](https://github.com/mortegro)! - WebSocket transport: enforce a bearer auth token end-to-end so a deploy target's
  issued `wsAuth` actually gates the socket.

  - `WebSocketServerTransport` accepts `authToken`. When set, every upgrade must
    present the matching token via the `skaile-bearer.<base64url>` subprotocol or
    the handshake is rejected with HTTP 401 (before any command handler runs).
    When unset, behaviour is unchanged (back-compatible).
  - `WebSocketClientTransport` accepts `auth`; sends it as the WS subprotocol
    (the one handshake field a browser client can populate).
  - `startAgentServer` threads `opts.authToken ?? SKAILE_WS_AUTH_TOKEN` into the
    default server, so `skaile serve` enforces the token from the environment.
  - New `@skaile/workspaces/transport` exports: `encodeBearerSubprotocol`,
    `decodeBearerFromHeader`, `tokensEqual`, `BEARER_SUBPROTOCOL_PREFIX`.
  - Robustness fix: `WebSocketClientTransport.connect()` now rejects when the
    socket closes or errors before opening (a rejected auth upgrade, or a refused
    port) instead of hanging until the connect timeout.

### Patch Changes

- [#68](https://github.com/skaile-ai/workspaces/pull/68) [`4073406`](https://github.com/skaile-ai/workspaces/commit/407340687ce038c9ae8a71cc8b80e237545a4db9) Thanks [@Frozen666](https://github.com/Frozen666)! - fix(base-assets/git): recurse into submodules on clone and pull. Git mounts cloned without `--recurse-submodules` and never ran `submodule update`, leaving submodule directories empty or stale on upstream pointer bumps. A `syncSubmodules()` helper now runs after every ref-changing operation (clone, bootstrap, session-branch checkout, sync pull, merge-on-close, periodic auto-pull/rebase); it no-ops when `.gitmodules` is absent and swallows submodule failures so a broken submodule remote can't abort the parent mount.

- [`cd3bee2`](https://github.com/skaile-ai/workspaces/commit/cd3bee27bd6e182b4d8f8d08f1816820edd2b0a7) Thanks [@mortegro](https://github.com/mortegro)! - Auto-clone url-backed source caches on miss. `ensureRepo` now clones a remote
  source into its managed `~/.skaile/sources/<slug>` cache dir when the clone is
  absent, instead of throwing `Local repository path not found` — so `skaile
install` works without a pre-populated clone. The source pin is no longer
  mis-encoded as a clone `--branch` (tag/SHA pins now reach `checkoutPin`), and a
  clone/auth failure surfaces as `missing: ["source:<slug> (<reason>)"]` rather
  than a bare slug.

## 0.22.0-beta.1

### Minor Changes

- [`ce3f33f`](https://github.com/skaile-ai/workspaces/commit/ce3f33f5b85f81d1c80689a99a8e63cf0c2afc07) Thanks [@mortegro](https://github.com/mortegro)! - Fix deploy-handle readiness timeout + dedupe deploy-target helpers.

  - **Fix:** `DeployHandle.waitReady(timeoutMs)` silently ignored its `timeoutMs`
    argument in the fly / k8s / vercel-sandbox targets, always using a hard-coded
    internal deadline. The caller's timeout is now honored.
  - **Fix (`@skaile/provider-fly`):** `deleteMachine` swallowed _all_ errors while
    only intending to ignore a 404, so `stop()` could falsely report success on a
    still-running (still-billing) machine. Non-404 errors now propagate.
  - **Added (`@skaile/workspaces/plugin-registry`):** shared deploy helpers —
    `buildStrategySchema` / `BuildStrategy`, abort-aware `sleep`, generic
    `pollUntil`, and the `makeDeployHandle` factory — replacing the per-provider
    copies of the sleep loop, handle wrapper, and build-strategy enum.

- [#65](https://github.com/skaile-ai/workspaces/pull/65) [`2af2e52`](https://github.com/skaile-ai/workspaces/commit/2af2e525588648e45a14fb8acdfde72f1ebb57e8) Thanks [@mortegro](https://github.com/mortegro)! - Add `listDeployTargets()` / `getDeployTarget()` to
  `@skaile/workspaces/plugin-registry`, the symmetric counterparts to
  `listDrivers()` / `listConnectors()` so UIs can enumerate all three plugin kinds
  uniformly. Both default to the process-wide `pluginRegistry`;
  `listDeployTargets()` returns lightweight `{ id, displayName }` rows and
  `getDeployTarget(id)` returns the full target without the override/yaml/default
  precedence `resolveDeployTarget` applies.

  Docs (`docs/deploy/`): documented the container-target image lifecycle — that
  `buildStrategy: "local"` is bring-your-own-image (`create()` never builds), how
  `pullPolicy` interacts with a locally-built vs. registry-published image, that
  `workspace.container.{packages,agent_clis,stack,nix}` are image-build inputs
  distinct from the deploy `config.image` (with skaile-platform's
  `skaile-vm-agent:thin` / Nix-stack model as the reference), and that the
  in-process runtime is deliberately out of scope for the deploy abstraction
  (no `wsUrl`).

- [#67](https://github.com/skaile-ai/workspaces/pull/67) [`c67a381`](https://github.com/skaile-ai/workspaces/commit/c67a381f1029bec547531e238d235636eef16740) Thanks [@mortegro](https://github.com/mortegro)! - Canonical-identity manifest architecture (BREAKING).

  Schema:

  - `skaile.yaml` top-level keys `repositories:` and `ai_resources:` are removed
    and now throw a parse error pointing at `docs/concepts/manifest-schema.md` and
    the `migrate-skaile-manifest` skill.
  - New publication half: `publisher`, `version`, `assets[]`.
  - New consumption half: collapsed `sources:[{url, pin?}]` (no `name`),
    `stores:[{url}]`, `dependencies:[<kind>:<name>@<publisher>[#pin]]`,
    `overrides:[{ref, source, reason}]` with required `reason:`.

  Dep ref grammar:

  - Canonical form `kind:name@<publisher>[#pin]`. `publisher` is required and
    GitHub-shaped. Pins accept SemVer constraints (^, ~, x), exact SemVer, a
    40-char SHA, or absent. Floating refs (main/latest/HEAD) throw.

  Resolution:

  - The resolver collects candidates from every source clone (via the new
    provenance-index walker) and optionally every store. Divergent sha256 at the
    same (publisher, kind, name, version) is a hard error
    (`CanonicalRefConflictError`) with a dep-chain walk. `overrides[]` pin a
    chosen source and flag `override_applied: true` on the lock entry. A cheap
    `catalog.getCanonicalDigest` probe cross-checks source vs store at the same
    version. `resolveAll` is now async.

  Lock file:

  - Schema v2. Keys are canonical refs `<publisher>/<kind>:<name>@<version>`;
    values carry `sha256`, `source.{url, commit}`, `files[]`, `override_applied`.
    Top-level `sources:` records every contributing URL+commit. Old v1 lockfiles
    must be deleted and regenerated; the reader emits a clear error.

  Types:

  - `AssetRef.repository` → `AssetRef.publisher` (and `CatalogEntry.publisher`).
  - `RepositoryDeclaration` → `SourceDeclaration`.
  - `LockEntry` reshaped; new `LockSourceEntry`, `LockFileEntry`,
    `StoreEntry`, `AssetEntry`, `OverrideEntry`.
  - `RemoteCatalogSource.getCanonicalDigest(ref)` added.

  Migration:

  - No `skaile manifest migrate` CLI command. The transform is performed by the
    `migrate-skaile-manifest` skill (lives in `ai-assets`).

- [#53](https://github.com/skaile-ai/workspaces/pull/53) [`4e3c2b2`](https://github.com/skaile-ai/workspaces/commit/4e3c2b27ed6a004e7b9c3d60b0ae3754a8192eb5) Thanks [@peteralbert](https://github.com/peteralbert)! - Support the BYO-flake recipe form on `AssetRecipe`.

  - `AssetRecipe` gains optional `flake` (the asset's own flake: `"."` or a flake URL) and
    `publisher` fields; `attr` is now optional and defaults to `"default"` for the flake form
    (the legacy `{ attr }` platform-flake form is unchanged and still requires `attr`).
  - New `validateAssetRecipeFlake` validates the flake source shape (`"."`, `github:`,
    `git+https://`, `git+ssh://`, `path:`; rejects bare paths, traversal, unknown schemes, and
    shell metacharacters). Exported alongside `DEFAULT_RECIPE_ATTR`.
  - `mcpDeclFromCatalogEntry` carries `flake`/`publisher` onto the decl; an invalid flake drops
    the whole recipe (no silent fall back to a platform-flake attr).
  - The runner resolves a flake-sourced recipe by `mcps.<id>` (the recipe id) rather than `attr`,
    since BYO-flake recipes all build `#default` and `attr` is not a unique map key.

### Patch Changes

- [#60](https://github.com/skaile-ai/workspaces/pull/60) [`69f9924`](https://github.com/skaile-ai/workspaces/commit/69f99243cff137da1f835777e298e0779dec45e2) Thanks [@peteralbert](https://github.com/peteralbert)! - Emit sidecar `.d.ts` files next to each tsup `.js` output so consumers and
  tools that look for a declaration file adjacent to the `.js` (classic node
  resolution, some bundlers, IDE quick-lookups, ad-hoc `grep` audits) find one.

  Previously, tsup emitted JS at `dist/<entry>.js` (e.g.
  `dist/core/workspace-config.js`) while `tsc` emitted `.d.ts` at the
  source-preserving path `dist/<src>/<entry>.d.ts` (e.g.
  `dist/core/src/workspace-config.d.ts`). The package.json `exports` map
  bridged these with separate `types` and `import` conditions — correct for
  node16/nodenext/bundler resolvers, but invisible to anything looking
  adjacent to the `.js`. A new post-build step (`build:dts-sidecars`) writes a
  thin re-export shim at the tsup output path that points at the canonical
  tsc-emitted declaration, eliminating the apparent stale-types mismatch
  reported on `@skaile/workspaces/core/workspace-config`.

- [#66](https://github.com/skaile-ai/workspaces/pull/66) [`90a2c61`](https://github.com/skaile-ai/workspaces/commit/90a2c61564b4dd91ff54a96d3dcff213c35e465f) Thanks [@mortegro](https://github.com/mortegro)! - deploy(container): make `isRunning()` async so the docker/podman `inspect` round-trip stays off the event loop. Previously `health()`/`restore()` called a synchronous `portableSpawnSync` inspect, freezing log pumps and other connections on each status poll.

- [#64](https://github.com/skaile-ai/workspaces/pull/64) [`f1b14f3`](https://github.com/skaile-ai/workspaces/commit/f1b14f3f9bed870efe6f8c7de2819cfbeeb444a2) Thanks [@mortegro](https://github.com/mortegro)! - fix(sdk): InProcessTransport now tracks real readiness and never drops commands

  `InProcessTransport.connected` previously returned a hard-coded `true`, so
  `LocalRuntime.isRunning` reported `true` even before `start()`. It now reflects
  whether a command handler is actually registered.

  `inject()` also silently dropped commands sent before the runner wired its
  `onCommand` handler (e.g. a `debug`/`prompt` issued during the ~50ms `start()`
  window, or against a lazily-started runtime), which made debug queries hang
  until their own timeout. Such commands are now buffered and flushed to the
  first handler, so none are lost.

  `LocalRuntime` gains `whenReady()`, a promise resolved once `startAgentServer`
  returns, so consumers can await wiring before issuing reply-expecting commands.

- [#60](https://github.com/skaile-ai/workspaces/pull/60) [`69f9924`](https://github.com/skaile-ai/workspaces/commit/69f99243cff137da1f835777e298e0779dec45e2) Thanks [@peteralbert](https://github.com/peteralbert)! - Add `unregister(kind, id)` to `PluginRegistry` so tests can swap a built-in
  target (e.g. the real `local` / `git` connector) for a fake within the
  process-wide singleton. Production callers should rarely reach for it —
  built-ins and loaded plugins register once at startup and live for the
  process — but the hook is needed for test isolation across describe blocks
  that share the registry.

## 0.22.0-beta.0

### Minor Changes

- [#54](https://github.com/skaile-ai/workspaces/pull/54) [`7bdaf11`](https://github.com/skaile-ai/workspaces/commit/7bdaf115c251136288a94a7f880b3a0f52145be8) Thanks [@mortegro](https://github.com/mortegro)! - Unified plugin registry + deploy providers.

  **Breaking**

  - Removed the external `registerDriver` / `registerConnector` APIs. Register a
    typed target via `pluginRegistry.register(kind, target)` instead
    (`@skaile/workspaces/plugin-registry`). `createDriver` / `getConnector` /
    `listConnectors` survive as resolve-only wrappers.
  - Dropped `ioredis` and `yjs` runtime deps and the bundled `redis` / `yjs`
    connectors. Install `@skaile/connector-redis` / `@skaile/connector-yjs` via
    `skaile plugin install`.
  - Renamed the coding-agent adapter command `skaile plugin` →
    `skaile integration` (install/uninstall/enable/disable/status/hook). The
    `skaile plugin` name now manages registry plugins.

  **Added**

  - `@skaile/workspaces/plugin-registry` — one process-wide `pluginRegistry` with
    three kinds (driver / connector / deployTarget), the Target contracts,
    `DeployHandle` / `DeployContext`, and `resolveDeployTarget` (override > yaml >
    default precedence).
  - `@skaile/workspaces/deploy` — built-in `local` / `docker` / `podman` deploy
    targets (loopback-only), `registerBuiltinDeployTargets()`, and the
    `.skaile/deploy/handle.json` store.
  - `@skaile/workspaces/connectors-shared` — minimal public connector API for
    extracted connector plugins.
  - `skaile.yaml` gains `plugins:` (project-local plugin store) and `deploy:`
    blocks; `skaile.lock.yaml` gains a `plugins:` slice.
  - CLI: `skaile deploy [up|down|status|logs]` and `skaile plugin
[install|remove|list]`.
  - `DRIVER_CATALOG` → `BUILTIN_DRIVER_CATALOG` (old name kept as an alias); new
    `BUILTIN_CONNECTOR_CATALOG`.

  See `docs/migration-v2-to-v3.md`.
  </content>

## 0.21.0

### Minor Changes

- [#50](https://github.com/skaile-ai/workspaces/pull/50) [`da32a4b`](https://github.com/skaile-ai/workspaces/commit/da32a4b38a28f6efc203f042250a55477036b0e0) Thanks [@peteralbert](https://github.com/peteralbert)! - Add a first-class `skaile.yaml` codec to `@skaile/workspaces/core`:
  `decodeSkaileYaml(text) → { config, diagnostics }` and
  `encodeSkaileYaml(config) → string`. Decode is total (never throws) and reports
  legacy/non-canonical shapes — camelCase keys, flat `agent-config`, the old
  `ai_resources` object — as `warning` diagnostics instead of silently dropping
  them, plus `warning`s for unrecognized `driver`/`provider`/`access` values.
  Encode emits canonical, deterministic output (hyphenated `agent-config`, stable
  key order, defined fields only) and preserves forward-compat fields. The
  existing `loadSkWorkspaceConfig`/`saveSkWorkspaceConfig`/`normalizeConfig` are
  rewired onto the codec, and `SkWorkspaceConfigFile` now carries optional
  `diagnostics`. This gives clients (the platform especially) one sanctioned
  decode → mutate → encode path instead of hand-serializing YAML, which is what
  led to the driver silently falling back to `omp`.

## 0.20.0

### Minor Changes

- [#47](https://github.com/skaile-ai/workspaces/pull/47) [`86cb471`](https://github.com/skaile-ai/workspaces/commit/86cb471c3dbac50d0beea1a90560d78efb9479f6) Thanks [@peteralbert](https://github.com/peteralbert)! - Runtime delivery of store-adopted MCP servers.

  - `loadMcpServerDeclarations` now scans materialized assets at
    `<workspace>/.skaile/assets/mcp-server/*/MCP.md` as a third declaration source, folding each
    sibling `.instance.json` (`resolvedSecrets` + string-valued `config`) into the server's `env`.
    Precedence is catalog-dep refs < materialized assets < explicit `mcp_servers:`. A subscribed
    MCP server now launches without any `skaile.yaml` entry.
  - `resolveRecipePath` resolves nix recipe out-paths from a prebuilt map first
    (`SKAILE_NIX_RECIPE_MAP`, default `/etc/skaile/mcps-out-paths.json`; shape = the platform
    flake's `all-out-paths` JSON), falling back to `nix path-info --offline`. This avoids
    re-evaluating the flake (and its `ai-assets` input) inside deployed containers.

### Patch Changes

- [#48](https://github.com/skaile-ai/workspaces/pull/48) [`cd84054`](https://github.com/skaile-ai/workspaces/commit/cd84054ed9f04c3ce42374d7a0842c04b4bd52c8) Thanks [@peteralbert](https://github.com/peteralbert)! - Fix driver falling back to `omp` when `skaile.yaml` uses a camelCase or flat
  `agent-config` block. `normalizeConfig` now also accepts `agentConfig`
  (camelCase) and a flat profile shape (`{ driver, model, provider }` without a
  `default:` wrapper), wrapping it as the `default` profile. The `aiResources`
  camelCase alias for `ai_resources` is accepted too. Previously such configs —
  produced by clients that serialize a runtime config object straight to YAML —
  were silently dropped, so the configured `claude-sdk` driver was ignored and
  the runner spawned the `omp` binary, failing with
  `Executable not found in $PATH: "omp"`.

## 0.19.0

### Minor Changes

- [#44](https://github.com/skaile-ai/workspaces/pull/44) [`bd14e1b`](https://github.com/skaile-ai/workspaces/commit/bd14e1be7d8af0819bb21581fc33e5308d199517) Thanks [@mortegro](https://github.com/mortegro)! - Add `dev_paths` to `.skaile-source.yaml` and an `includeDev` discovery option.

  Assets under a source config's `dev_paths` (e.g. `ai-assets-dev/`) are now
  excluded from a normal discovery run and re-included only when discovery is
  invoked with `includeDev`. This lets a package ship public, consumer-facing
  skills in `ai-assets/` while keeping project-development skills in
  `ai-assets-dev/` out of a normal install. Honored by the glob walker, the
  author-shipped manifest path, and the virtual-tree walker; threaded through
  `LocalCatalogSource.sync({ includeDev })` and surfaced as `--dev` on
  `skaile source sync` / `skaile source add`.

### Patch Changes

- [#46](https://github.com/skaile-ai/workspaces/pull/46) [`90bf67c`](https://github.com/skaile-ai/workspaces/commit/90bf67cb6a529ba0814bf6400a0206c56ae2476c) Thanks [@peteralbert](https://github.com/peteralbert)! - fix(discovery): default versionless assets to `0.1.0` instead of `0.0.0`

  Assets whose manifest declares no `version` field now default to `0.1.0` — a
  real, adoptable version — rather than the `0.0.0` placeholder. The Catalog
  rejects `0.0.0` at publish time, so the old default produced unpublishable
  (and therefore unadoptable) catalog entries. Both discovery paths are fixed:
  the local-FS orchestrator (`discover.ts`) and the virtual-tree orchestrator
  (`tree-entries.ts`).

## 0.18.0

### Minor Changes

- [#42](https://github.com/skaile-ai/workspaces/pull/42) [`ecdc900`](https://github.com/skaile-ai/workspaces/commit/ecdc900790773d93b3f18983b785acea15c600db) Thanks [@peteralbert](https://github.com/peteralbert)! - Add fleet-managed short-circuit to the in-container git connector driver.

  When `SKAILE_FLEET_MANAGED_MOUNTS` lists the connector's mount id, the host's `GitFleetService` has already cloned the bare repo and bind-mounted a shared worktree at `ctx.mountTarget`. The driver now verifies the bind via the existing `ensureFleetMounted` helper, records the current branch for diagnostics, and starts a watcher so the workspace explorer still surfaces changes — then short-circuits `sync` / `onHibernate` / `onSessionClose` and the watcher / managed-credential teardown in `disconnect`. Host supervisor owns clone, fetch, commit, push, and merge.

  Standalone (non-fleet) sessions are unchanged: when the env does not list the mount id, the driver falls through to the existing clone path.

## 0.17.1

### Patch Changes

- [#39](https://github.com/skaile-ai/workspaces/pull/39) [`31b3037`](https://github.com/skaile-ai/workspaces/commit/31b30374e17043d099a30aac8e3ac08fca6c94e0) Thanks [@peteralbert](https://github.com/peteralbert)! - fix(release): rewrite `workspace:*` ranges before `changeset publish`

  `@changesets/cli` leaves `workspace:*` in `package.json` and relies on the
  underlying publisher to rewrite it. `npm publish` (which `changeset publish`
  shells out to) does not, so `workspace:*` was leaking into the tarballs of
  `skaile-cli`, `@skaile/workspace-bridge-react`, and
  `@skaile/workspace-bridge-vue` published at `0.16.0`. Installing them failed
  with `EUNSUPPORTEDPROTOCOL Unsupported URL Type "workspace:"`.

  The new `scripts/release.mjs` wrapper resolves each `workspace:` range to a
  concrete semver range before invoking `changeset publish`, then restores the
  originals afterward. This patch republishes the three affected packages with
  the correct deps; `@skaile/workspaces` itself was unaffected but bumps in
  lockstep with the fixed group.

- [#37](https://github.com/skaile-ai/workspaces/pull/37) [`a820dfa`](https://github.com/skaile-ai/workspaces/commit/a820dfaf15fda0b38315f30bf24f34d1e81832b2) Thanks [@mortegro](https://github.com/mortegro)! - Fix asset domain extraction in `scanDirectory` so `skaile manage` groups skills (and other assets) under their actual domain folder.

  Previously the scanner used the first path segment relative to the scan root as the domain. For sources whose assets live under a wrapper directory (e.g. `<repo>/ai-assets/<domain>/skills/<name>/SKILL.md`) every asset ended up with `domain="ai-assets"`, collapsing the whole catalog into a single group. The scanner now walks each path looking for a known kind directory (`skills`, `agents`, `flows`, `connectors`, `prompts`, `bundles`, `contracts`, `knowledge`, `mcp-servers`, `profiles`, singular `agent`) and uses the directory **immediately above** it as the domain. The bundle-at-domain-root case keeps working via a fallback to the file's parent directory.

  Also tightened `??` to `||` in the `manage` TUI so empty-string fallbacks bucket into the `"other"` group instead of leaking blank headers.

## 0.17.0

### Minor Changes

- [#38](https://github.com/skaile-ai/workspaces/pull/38) [`ff8f026`](https://github.com/skaile-ai/workspaces/commit/ff8f0262b142420bb2601225e99b059c8ab2edaf) Thanks [@Frozen666](https://github.com/Frozen666)! - Add `session.fromRef` option to the git mount driver so a freshly-created session branch can be anchored at an arbitrary ref instead of always branching from the cloned base branch.

  When the session branch does not yet exist on origin and `fromRef` is set, the driver runs `git fetch origin <fromRef>` followed by `git checkout -b <sessionBranch> FETCH_HEAD`. If the fetch fails (ref doesn't exist on the remote), the driver logs a warning and falls back to the previous behavior (branch from base). When the session branch already exists on origin, `fromRef` is ignored — existing branches win.

  Consumed by the platform's session-fork flow: forks now pass `fromRef = "skaile/session-<source-slug>"` so the fork branches off the source's tip rather than off `main`. Closes the runtime half of platform B-103.

## 0.16.0

### Minor Changes

- [#32](https://github.com/skaile-ai/workspaces/pull/32) [`ef21eb6`](https://github.com/skaile-ai/workspaces/commit/ef21eb6dd08c75b2bee651bd5c96c9da79f9d21d) Thanks [@mortegro](https://github.com/mortegro)! - Make `skaile.yaml` the source of truth for project sources, and broaden `skaile manage`'s Assets tab.

  - New `sources:` field in `skaile.yaml` (`{ name, url, branch? }`), parsed/merged/serialized through `core/workspace-config` and editable via `WorkspaceYamlEditor.{getSources,setSource,removeSource}`.
  - `skaile source add/list/show/remove/sync` now read and write the project's `skaile.yaml` instead of the machine-global `index.db` libraries table. The `~/.skaile/sources/<name>/` clone and the manifest cache in `index.db` are treated as exactly that — a cache.
  - `AssetManager.loadConfig()` merges `sources:` entries into its repositories map, so `skaile install` and `skaile manage`'s Assets tab automatically see assets from project sources.
  - New `ensureSourcesCloned(projectDir)` helper clones any sources declared in `skaile.yaml` but missing from `~/.skaile/sources/`. Wired into `skaile init` and `skaile install` so checking out a project hydrates its source clones.
  - `skaile manage` Assets tab pulls from three feeds: project sources (`skaile.yaml`), user authoring libraries (`LocalIndex`), and the remote store catalog when configured.

### Patch Changes

- [#32](https://github.com/skaile-ai/workspaces/pull/32) [`ef21eb6`](https://github.com/skaile-ai/workspaces/commit/ef21eb6dd08c75b2bee651bd5c96c9da79f9d21d) Thanks [@mortegro](https://github.com/mortegro)! - Fix `Dynamic require of "fs" is not supported` crash on `skaile source add` and other CLI commands.

  tsup's ESM bundler replaces in-source `require(...)` calls with a shim that throws at runtime. Converted the remaining dynamic requires in `library/local/db.ts` (`assertNoLegacyLibDb`), `library/local/store-paths.ts`, `connectors/cli.ts`, `asset-manager/index.ts`, and `asset-manager/installer.ts` to static `import` statements so the published bundle no longer hits the shim.

## 0.15.1

### Patch Changes

- [#33](https://github.com/skaile-ai/workspaces/pull/33) [`bbf0cdd`](https://github.com/skaile-ai/workspaces/commit/bbf0cddf414c68852495c82488217e0c535bfd86) Thanks [@mortegro](https://github.com/mortegro)! - **`skaile manage` TUI: align with source/library/store vocabulary.**

  Reorganizes the interactive TUI around the three-noun model that the rest of
  the CLI already uses (source / library / store):

  - Tabs are now `[Assets] [Sources] [Libraries] [Pending] [Sync]`, with `Assets`
    as the default landing tab. The new `Sources` tab lists registered github
    sources (libraries with `backend: "git"` under `~/.skaile/sources/`); the
    `Libraries` tab now only shows authoring places (`local`/`git` backends,
    sources excluded).
  - The assets list is grouped by **source** (`CatalogEntry.repository`) — what
    used to render as a "repo" header is now a "source" header.
  - Asset state previously called `deployed` is rendered as `installed`, matching
    the `skaile asset install` command.
  - Empty-state hints, footer hints, and shell-hint messages now point at the
    correct commands (`skaile source add <git-url>` for sources,
    `skaile library init <name>` for libraries) — the previously suggested
    `skaile library add <git-url>` no longer exists.
  - `LibraryRow.backend` is narrowed to `"local" | "git"` (the `"store"` backend
    was removed in the earlier vocabulary cleanup).

  No public API changes. Internal `manage.ts` exports used by the test suite
  were renamed: `repoAssetRefs → sourceAssetRefs`,
  `AssetRow.type "repo-header" → "source-header"`, `AssetRow.repo → source`,
  `AssetRow.deployed → installed`.

- [#34](https://github.com/skaile-ai/workspaces/pull/34) [`50cd056`](https://github.com/skaile-ai/workspaces/commit/50cd0562162554f76a19d9e2d15c413dc81a4db4) Thanks [@peteralbert](https://github.com/peteralbert)! - Add `skaile-cli` (an `npx`-able wrapper) and fix ESM dynamic-`require` failures that broke `skaile install <ref>`.

  - **`skaile-cli`** — new thin wrapper around `@skaile/workspaces` that lets users run `npx skaile-cli install <publisher>/<name>@<version>` without globally installing anything. The shorter `skaile` name is currently blocked by npm's name-similarity policy (too close to `sails`); appeal is in flight and the wrapper will be republished under that name if granted.
  - **`@skaile/workspaces`** — replace runtime `require(...)` calls in `library/src/local/db.ts`, `library/src/local/store-paths.ts`, `asset-manager/src/installer.ts`, `asset-manager/src/index.ts`, and `connectors/src/cli.ts` with static `import` declarations. The published bundle is ESM; under pure-ESM consumers (including `npx`) there is no `require` in scope, so esbuild's fallback shim threw `Dynamic require of "fs" is not supported` during install + a handful of other commands.

## 0.15.0

### Minor Changes

- [`82b290f`](https://github.com/skaile-ai/workspaces/commit/82b290f12620e5ca655587d6603b475cdbfb9d7d) - **Source/Library/Store vocabulary cleanup — PR-1 (types & interfaces rename).**
  Finishes the 2026-05-13 Libraries-redesign split-brain by removing the
  duplicate `Source` types in favour of the `Library` vocabulary, with deprecated
  aliases for one release so consumers can migrate.

  **Renames (deprecated aliases provided for one release):**

  - `UserLibrary` → `Library`
  - `UserLibraryManager` → `LibraryManager`
  - `LocalLibrary` → `LocalIndex`
  - `LocalLibraryOptions` → `LocalIndexOptions`

  **Removed types (no aliases — never had non-internal callers):**

  - `Source`, `SourceType`, `AddSourceInput` — superseded by `Library` /
    `AddLibraryInput` from `user-library.ts`.
  - `ILibrary` (alias of `IAssetIndex`) — use `IAssetIndex` directly.
  - `StoreBackendConfig` and the `"store"` value in `LIBRARY_BACKENDS` —
    contradicted the pointer-only Store design (the Store never holds bytes).
    `StoreSyncDriver` was removed alongside.

  **Field renames:**

  - `SyncResult.sourceId` → `SyncResult.libraryId`
  - `AssetDefinition.sourceId` → `AssetDefinition.libraryId`
  - `AssetFilter.sourceId` → `AssetFilter.libraryId`

  **CLI behavior:** unchanged, except `skaile library init --store <url>` now
  exits with code 2 and prints a migration hint pointing at the future
  `library link` / `library publish` flow (PR-2).

- [`14aeb68`](https://github.com/skaile-ai/workspaces/commit/14aeb68f1e3cc658bac5fc0e3a4efa87776f03aa) - **Source/Library/Store vocabulary cleanup — PR-3 (drop deprecated type aliases).**
  Removes the one-release deprecation aliases introduced in PR-1. Consumers
  must now import the new names.

  **Removed deprecated aliases:**

  - `UserLibrary` — use `Library`
  - `UserLibraryManager` — use `LibraryManager`
  - `LocalLibrary` — use `LocalIndex`
  - `LocalLibraryOptions` — use `LocalIndexOptions`

  Re-exports stripped from `@skaile/workspaces/library` barrel; source-level
  alias definitions deleted from `library/src/user-library.ts`,
  `library/src/local/library.ts`, and `library/src/local/library-manager.ts`.
  No runtime behavior change.

- [`bcdb30e`](https://github.com/skaile-ai/workspaces/commit/bcdb30ed3c7a67e385e6338ae15c68ca469dd13c) - **Source/Library/Store vocabulary cleanup — PR-5 (drop one-shot migrations + deprecated CLI surface).**
  Closes out the 2026-05-27 cleanup by removing the transitional shims that
  shipped in PR-1 and PR-2 for one release.

  **Removed:**

  - `lib.db` → `index.db` one-shot relocation in
    `library/src/local/db.ts`. Opening a LocalIndex next to a leftover
    `lib.db` now hard-fails with an actionable message instructing the
    user to move the file (and any `-wal` / `-shm` siblings) to
    `index.db` themselves.
  - `openUserLibraryManager` deprecated re-export from
    `cli/src/open-library.ts` — use `openLibraryManager`.
  - Stale documentation in `library/CLAUDE.md`, `cli/CLAUDE.md`,
    `library/README.md`, and inline comments that described
    `SKAILE_LIBRARIES_DIR` / `SKAILE_LIBRARY_DIR` as deprecated aliases
    for `SKAILE_INDEX_PATH`. `SKAILE_LIBRARIES_DIR` is a separate,
    non-deprecated variable for the library checkouts directory;
    `SKAILE_LIBRARY_DIR` was never honoured by the code.

  **Migration:** if `~/.skaile/libraries/lib.db` still exists on a host
  that has not opened skaile since the prior release, run
  `mv ~/.skaile/libraries/lib.db ~/.skaile/index.db` (move any `-wal` /
  `-shm` siblings too).

## 0.14.0

### Minor Changes

- [#29](https://github.com/skaile-ai/workspaces/pull/29) [`83b3454`](https://github.com/skaile-ai/workspaces/commit/83b3454c4597956b73ff12e80c2d7ef70f9193d2) Thanks [@peteralbert](https://github.com/peteralbert)! - Add `googledrive` connector: filesystem-face Google Drive (Google Files) mount
  backed by rclone's `drive` backend, mirroring the `sharepoint` and `webdav`
  connectors. Supports OAuth bearer tokens, JSON OAuth bundles with refresh,
  service-account JSON files, configurable scope, and optional root folder /
  Shared Drive restrictions. Adds a pure `renderGoogleDriveConfig` helper to
  `@skaile/workspaces/connectors/rclone-config` for shared INI rendering between
  the in-container driver and the platform-side fleet service.

## 0.13.0

### Minor Changes

- [#27](https://github.com/skaile-ai/workspaces/pull/27) [`16d1299`](https://github.com/skaile-ai/workspaces/commit/16d12995721707fb504f6081b44d67f2447067f9) Thanks [@mortegro](https://github.com/mortegro)! - **`skaile init` redesign: minimal bootstrap, no bundled templates.** The
  template-driven scaffolder, all eight layer modules, both bundled templates
  (`base`, `dev-agent`), the `scaffoldWorkspace()` / `listTemplates()` /
  `setTemplatesDir()` public API, the `ScaffoldOptions` / `ScaffoldResult` /
  `TemplateManifest` types, and the `./asset-manager/scaffold` subpath export
  are removed.

  `skaile init [project-dir]` now does only the irreducible setup: writes
  `.skaile/`, `settings.json`, `skaile.yaml`, the driver-target directory
  (`.claude/`, `.omp/`, or `.codex/`), an optional `.gitignore` + `git init`,
  and bootstraps `~/.skaile/config.yaml` on first run. There is no template
  choice and no agent imprinting — workspaces start neutral. Opinionated
  starters (personas, skill sets, connector sets) will compose afterwards via
  the Asset Store v2 preset system (`PresetManifestSchema`, `parsePresetYaml`,
  nested ref resolution, placeholders); the `preset apply` runner is the
  follow-up that completes this surface.

  **Flag changes (breaking):**

  - `--framework <name>` → `--backend <name>` (accepts `claude-code` | `omp` |
    `codex`, default `claude-code`).
  - `--template`, `--docker`, `--skip-install` removed.
  - `--no-git` added to suppress `git init` and the `.gitignore` write.

  **Internal moves:**

  - `installAgent` (the GitAgent → framework-native renderer used by
    `skaile install`) moved from `asset-manager/src/scaffold/layers/agents.ts`
    to `asset-manager/src/install-agent.ts` and is now re-exported from
    `@skaile/workspaces/asset-manager`.

## 0.12.2

### Patch Changes

- [`8f38d9d`](https://github.com/skaile-ai/workspaces/commit/8f38d9de23f1de6938c3a0865a100b3a6b640ecd) - **Lockstep versioning.** `@skaile/workspaces`, `@skaile/workspace-bridge-react`, and `@skaile/workspace-bridge-vue` are now grouped under a Changesets `fixed` set, so the three packages always share the same version number going forward. This release unifies them: the bridge packages jump from `0.8.14` to whatever version this PR lands `@skaile/workspaces` on (skipping the intermediate `0.8.x` line), and every future release will bump all three together regardless of which package the underlying change touched.

## 0.12.1

### Patch Changes

- [`3ec05df`](https://github.com/skaile-ai/workspaces/commit/3ec05dfb162c5d40003d4eae012753c460bac754) - `skaile --version` (and the new `-v` short alias) now prints the real package version read from `package.json` at startup, instead of the hardcoded `0.1.0` placeholder that the CLI had been shipping with. This keeps the flag in sync with whatever changesets-driven release the binary was built from, so users can reliably check which release of `@skaile/workspaces` they have installed.

## 0.12.0

### Minor Changes

- [#21](https://github.com/skaile-ai/workspaces/pull/21) [`e06401f`](https://github.com/skaile-ai/workspaces/commit/e06401f771ac58b484ccbf7e465d3048e7e7a70d) Thanks [@peteralbert](https://github.com/peteralbert)! - Dependency upgrades.

  - **Optional peer ranges raised** (consumers using these drivers should move to the new ranges): `@anthropic-ai/claude-agent-sdk` `^0.2.89` → `^0.3.150`, `@openai/codex-sdk` `^0.118.0` → `^0.133.0`. No driver code changes were required — the used API surface is unchanged.
  - **Runtime dependencies bumped:** `commander` 13 → 14, `@clack/prompts` 0.9 → 1.4, `ulid` 2 → 3, `@octokit/rest` → 22.0.1, `semver` → 7.8.1, `ws` → 8.21.0. No source changes needed.

  `marked` is intentionally held at 15.x — it is capped by `marked-terminal`'s peer range (`>=1 <16`) until an upstream release lifts it.

### Patch Changes

- [#20](https://github.com/skaile-ai/workspaces/pull/20) [`843b611`](https://github.com/skaile-ai/workspaces/commit/843b6113075e6ecfd041d66ff161357f5eca1fb6) Thanks [@peteralbert](https://github.com/peteralbert)! - Remove the unused `glob` direct dependency. It was declared in `dependencies` but never imported anywhere in the source, and it resolved to the now-deprecated `glob@11.1.0`, which printed a deprecation warning on `npm i -g @skaile/workspaces`. Dropping it removes the warning and shrinks the install footprint.

## 0.11.2

### Patch Changes

- 4d91d69: fix(bridge): self-heal expired Claude OAuth credentials that surface as "Not logged in"

  The claude-sdk driver's credential self-heal (`onAuthError`) failed to fire in two
  cases, leaving sessions stuck on a misleading `Not logged in · Please run /login`
  error after the OAuth access token expired:

  - **Classification**: the bare `Not logged in · Please run /login` string the
    Claude Code CLI emits (no JSON body) fell through to `category: "unknown"`, so it
    was never promoted to `AuthError` and the self-heal never triggered. It is now
    classified as `auth`.
  - **Retry budget**: a stale-resume retry ("No conversation found with session ID")
    consumed the single shared retry slot, so a subsequent auth failure on the
    fresh-session retry could not reach the self-heal branch. Each recovery path
    (stale-resume, poisoned-transcript, auth) now owns an independent one-shot budget.

  Together these let the runner re-mint the credential and continue the turn instead
  of dead-ending. Surfaced by a prod cloud session (2026-05-21).

## 0.11.1

### Patch Changes

- ae93713: `webdav` and `sharepoint` connectors now `chmod 0o770` on every directory
  they `mkdir` (mount target, vfs cache, cache root) via a new local
  `ensureDirMode()` helper. `RcloneProcessManager.spawn()` does the same on
  the mountPoint and cacheDir it creates.

  `mkdirSync(mode:)` is masked by the process umask — default 022 yields
  mode 0750 and strips the group-write bit `fusermount3` requires on the
  mountpoint and rclone needs on the vfs cache. Explicit `chmod` is
  umask-immune. EPERM on chmod is swallowed: a foreign-uid pre-create is
  recovered by the platform's agent container entrypoint (see
  skaile-ai/platform#139), so the runner staying quiet here is intentional
  defence in depth rather than a swallowed failure.

  Fixes the production symptom where a Nextcloud-backed session failed
  after a platform restart with `fusermount3: user has no write access to
mountpoint /skaile/.mounts/workspace` and
  `mkdir /var/cache/skaile/.../vfs/skaile-workspace: permission denied`.

## 0.11.0

### Minor Changes

- c0f3b15: Two-part hardening of the AI-credential 401 path:

  **Bridge: defer the auth-error event when self-heal is pending.** The
  `claude-sdk` driver used to emit a `{ type: "error" }` agent-event from
  `failTurn()` _before_ its `onAuthError` self-heal callback decided
  whether the credential could be refreshed. The runner forwarded every
  such event to the platform, so users saw a flash of `Failed to
authenticate. API Error: 401` even on every successful self-heal.
  `failTurn()` now suppresses the downstream emission when the failure
  is an `AuthError` on the first attempt AND an `onAuthError` callback is
  configured; the event is emitted only if the self-heal callback returns
  failure (or throws), or if the retry's own `failTurn` runs. Every auth
  failure still produces exactly one downstream `error` event — none on
  silent recovery, one on terminal failure.

  **Runner: proactive AI-credential refresh.** New
  `createAiCredentialRefreshScheduler` factory in
  `runner/src/ai-credential-refresh.ts`. After the initial Claude Code
  credentials blob lands at `session_init`, the runner parses
  `claudeAiOauth.expiresAt`, schedules a single `setTimeout` to fire 5
  minutes before expiry, and on fire calls
  `host.refresh_credential { reason: 'refresh' }` via the existing
  mediator path. Successful mints (proactive or reactive retry-401) chain
  the next fire. Static tokens (`expiresAt: null`) and standalone CLI
  sessions short-circuit to no-op. The reactive retry-401 path stays as
  the safety net. Cancelled in `cleanup()` so SIGTERM / hibernate does
  not leak the timer.

  The new helper `extractClaudeAiOauthExpiresAt(json)` is exported from
  the runner for callers that need to inspect the credentials blob
  without persisting it.

  Pair with the platform-side enablement of `SKAILE_AIP_AUTO_REFRESH` to
  also pre-warm the platform's credential cache every 15 minutes; the
  runner's proactive timer is the primary defence against in-container
  401s and is independent of that flag.

## 0.10.1

### Patch Changes

- addb191: `ConnectorManager.buildWrappedMediator` now falls back to the legacy
  `mount:<id>` pre-mint namespace when no `connector:<id>` entry is found,
  emitting a single `warn` log on each fallback hit
  (`"connector credential resolved via mount-namespace fallback"`).

  Defence-in-depth companion to the platform-side pre-mint namespace
  alignment (2026-05-19 incident): when the platform writer files git
  credentials under the pre-unified key, the runner still resolves the
  token and the warn log surfaces the drift so operators can detect a
  platform regression. The primary `connector:` lookup path is unchanged
  and incurs no extra work.

## 0.10.0

### Minor Changes

- eb14ab2: Add runtime support for agent-to-agent (session-to-session) communication:
  - New `a2a_message` event kind in the agent event union.
  - `SessionDispatcher.onceNextFinished` — a one-shot hook that resolves on the
    session's next finished assistant turn, used to capture a peer's answer to a
    synchronous `ask`.
  - `SessionDispatcher.deliverPrompt` — delivers a turn-triggering prompt without
    persisting it to the message store (A2A message persistence is owned by the
    caller).
  - Per-capability `callTimeoutMs` on the capability definition + wire format, so
    a long-running capability call (e.g. a 5-minute `ask_session`) can exceed the
    default capability-call timeout.
  - `buildLinkedPeersPromptSection` — renders a `<LINKED_PEERS>` system-prompt
    block from a session's linked peers, exported via the new
    `@skaile/workspaces/runner/prompt-assembly` subpath.
  - `PROTOCOL_VERSION` bumped to `3.3.0` for the additive A2A wire surface
    (`a2a_message` event + capability `callTimeoutMs`); also backfills the
    `3.2.0` changelog entry for the previously-undocumented resume cascade.

## 0.9.1

### Patch Changes

- 4e7f03a: **Fix B-30: private `@mention_` messages no longer reach the agent.**
  `SessionDispatcher.sendCommand` now suppresses the agent forward when
  `visibility.visibilityMode` is `Private` and `privateRecipientIds` does not
  include the `"__agent__"` sentinel. Private human-to-human messages are still
  persisted and broadcast to their named human recipients, but the LLM never
  sees their content. Private messages that explicitly address the agent
  (`@agent_`, encoded as the `"__agent__"` sentinel) continue to forward
  normally, as do `Public` and visibility-less messages. This closes the same
  privacy hole that the existing `HumansOnly` gate already covered, for the
  `Private` recipient-scoped case.
- 43895e8: **Self-heal poisoned Claude SDK transcripts.** The `claude-sdk` driver now
  recovers from a conversation history that the Anthropic Messages API
  permanently rejects because a content block is malformed. Previously such a
  transcript bricked the session: every replayed turn failed with the same
  `400 invalid_request_error` and there was no in-band recovery.

  When `ClaudeSdkDriver.prompt()` catches that error it calls
  `scrubPoisonedTranscript()` to repair the on-disk SDK JSONL transcript, then
  retries the resume once, keeping the same session so conversation context is
  preserved. Four poison classes are repaired in a single pass:

  - **Image `media_type` mismatch** — corrected by sniffing the base64 magic
    bytes (Claude Code `Read`-tool bug, anthropics/claude-code#55338 / #30124).
  - **Missing image `media_type`** — filled in from the sniffed bytes (#33179).
  - **Oversized images** — an image whose decoded payload exceeds the API's 5 MB
    per-image limit is replaced with a text stub (#34566).
  - **`cache_control` on empty text blocks** — the rejected marker is stripped
    (#59626).

  Genuinely unidentifiable image blocks are also replaced with a text stub. Adds
  the `scrubPoisonedTranscript` and `sniffImageMediaType` exports, the
  `ScrubTranscriptResult` type (with `corrected` / `stubbed` / `cacheStripped`
  counters), and a `jsonl_poisoned` reason on the `resume_failed` event.

- 0f140a1: **Binary-aware filesystem-mount write.** `handleMountResourceRequest`'s `write`
  operation now decodes `content.data` as base64 when `content.encoding` is
  `"binary"`, and creates missing parent directories (`mkdir -p`) before writing.
  Previously it wrote `content.data` verbatim as utf-8, so a base64-encoded
  payload landed on disk as its literal base64 string, and writing into a
  not-yet-existing subdirectory failed. This brings the mount `write` op to
  parity with the already-binary-capable `read` op, and is the runner-side write
  path the platform's `workspace-upload` (drag & drop file upload) route
  dispatches into.

## 0.9.0

### Minor Changes

- 0e2cc85: **Upgrade to Zod 4.** The `zod` dependency moves from `^3` to `^4`, and the
  `zod-to-json-schema` package is dropped — Zod 4 ships a native
  `z.toJSONSchema()` that `defineCapability` now uses to convert capability
  input/output schemas (`target: "draft-07"`, with `io: "input"` / `io: "output"`).
  All `z.record(value)` calls were migrated to the two-arg `z.record(key, value)`
  form required by Zod 4, and the deprecated `.strict()` / `.passthrough()` object
  methods were replaced with the `z.strictObject(...)` / `z.looseObject(...)`
  factories. The `defineCapability` public API and emitted JSON Schema shape are
  unchanged; behavior is preserved across the upgrade.

### Patch Changes

- d581c0e: **CommonJS consumability.** Every `exports` subpath (and the package root) now
  carries a `default` condition, so the package can be loaded with Node's
  `require()` from a CommonJS consumer — previously every subpath threw
  `ERR_PACKAGE_PATH_NOT_EXPORTED` because the map declared only an `import`
  condition. The package stays ESM-only; `default` points at the same ESM
  artifact as `import`, and Node 24's stable `require(esm)` loads it
  synchronously. The exports generator (`_scripts/gen-workspaces-manifest.ts`)
  emits `default` for all subpaths, and a new `check:cjs` smoke test
  (`_scripts/check-cjs-require.ts`, wired into CI) `require()`s every published
  subpath to guard against regressions.

All notable changes to `@skaile/workspaces` are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/).

## 0.8.5 — 2026-05-16

### Fixed

- **Runtime crash on packages without the optional Claude Agent SDK.** Two files in
  `runner/src/` — `resources.ts` and `serve.ts` — carried an eager, module-scope
  `import * as sdk from "@anthropic-ai/claude-agent-sdk"` whose binding was never
  used. Because those files are part of the `@skaile/workspaces/runner` subpath and
  the `skaile` CLI entry, any installation that omitted the optional SDK crashed
  immediately with `Cannot find module '@anthropic-ai/claude-agent-sdk'`. The dead
  imports have been removed. `0.8.5` supersedes the broken `0.8.4`, `0.8.3`, and
  `0.8.2`.

### Changed

- **Backend drivers are now isolated behind lazily-loaded subpath modules.**
  `bridge/src/registry.ts` no longer statically imports any concrete driver module or
  auto-registers drivers at load time. Each `bridge/src/drivers/<id>.ts` calls
  `registerDriver` at its own module bottom, so loading the driver module is what
  registers it. Bridge core has zero references to any concrete backend. The new
  `loadDriver(id)` function in bridge core is the single id → module map; it loads
  each driver via `@skaile/workspaces/drivers/<id>`, using literal-string `await
import()` specifiers so `bun --compile` can statically analyse them. `createDriver`
  is now `async` — it calls `loadDriver` before constructing the driver instance. This
  is a pre-1.0 breaking signature change; all call sites have been updated.
- **Four `./drivers/<id>` subpath exports added to `@skaile/workspaces`.** Consumers
  can `await import("@skaile/workspaces/drivers/omp")` etc. to load and register a
  specific backend without pulling any other driver into the module graph.

### Added

- **`_scripts/build-binary.ts` and `build:binary` npm script.** Compiles the `skaile`
  CLI to a standalone Bun binary. The default profile bundles `omp`, `echo`, and
  `claude-sdk` (defines `__INCLUDE_CLAUDE_SDK__=true`, `__INCLUDE_CODEX__=false`);
  passing `--with-codex` on the command line adds Codex. The compiled binary honours
  `bun --compile` dead-code elimination: excluded backends and their SDK packages are
  stripped from the output.
- **`skaile serve --with-codex` flag.** The `--compile-test` smoke-build path in
  `skaile serve` now accepts `--with-codex` to produce a Codex-enabled test binary,
  matching the `build:binary` profile flags.
- **Build-time constants `__INCLUDE_CLAUDE_SDK__` and `__INCLUDE_CODEX__`** gate the
  `claude-sdk` and `codex` branches inside `loadDriver`. When undefined (tsup/vitest
  on raw source), both default to `true` via a `typeof` guard so no build step is
  required in development.

## 0.8.4 — 2026-05-16

### Fixed

- **`skaile` binary restored in the published package.** The publish workflow
  moved to `npm publish` (required for OIDC trusted publishing — `bun publish`
  has no OIDC support). Unlike `bun publish`, `npm publish` ignores
  `publishConfig.bin` and also strips `bin` paths with a leading `./`, so
  `0.8.2` and `0.8.3` shipped without the `skaile` CLI binary. Fixed by adding
  `_scripts/materialize-pkg.ts` (hoists `publishConfig` manifest overrides into
  the top-level manifest before packing) and changing `publishConfig.bin` to
  `dist/cli/index.js` (no `./` prefix). `0.8.2` and `0.8.3` are deprecated; use
  `0.8.4` or later.

## 0.8.2 — 2026-05-16

First standalone-publishable release. `@skaile/workspaces` is no longer a Bun
workspace root — it builds, tests, and publishes from a plain checkout without
the `skaile-dev` shell repo.

### Changed

- **Plugin distribution collapsed.** `@skaile/workspaces` is the single published
  package; coding-agent integration is generated in place by
  `skaile plugin install`. The `@skaile/plugin-claude-code` and
  `@skaile/plugin-omp` packages were removed. The runtime Library and
  `skaile mcp-server` run on plain Node (`@libsql/client`, raw
  `@modelcontextprotocol/sdk`).
- **REPL merged in.** The interactive REPL — formerly the separate
  `@skaile/agent-tui` package — is now the `@skaile/workspaces/tui` subpath
  export. `skaile repl` lazy-imports it.
- **React/Vue bindings extracted.** `@skaile/workspace-store-react` and
  `@skaile/workspace-store-vue` moved to their own repositories and were renamed
  `@skaile/workspace-bridge-react` (`skaile-ai/workspace-bridge-react`) and
  `@skaile/workspace-bridge-vue` (`skaile-ai/workspace-bridge-vue`). They depend
  on `@skaile/workspaces` via a published npm range and publish from there.
- The `workspaces` array and the `@skaile/agent-tui` optional dependency were
  removed from `package.json`; the publish workflow now publishes only
  `@skaile/workspaces`.

### Fixed

- Every optional peer dependency exercised by the build and test suite
  (`react`, `vue`, the OpenTelemetry SDK, the Claude/Codex agent SDKs, `xstate`,
  `@xstate/store`, `yjs`, `sql.js`) is now also declared as a `devDependency`,
  so `typecheck`, `build`, and `test` pass on a standalone checkout.
- `@opentelemetry/exporter-trace-otlp-http` bumped to `^0.218.0` to match the
  `@opentelemetry/sdk-trace-node` 2.x line — the stale `^0.57.0` pin pulled a
  duplicate, incompatible `@opentelemetry/sdk-trace-base`.

## 0.8.1 — 2026-05-15

Publish-readiness fixes. No public API or subpath-export changes from 0.8.0 —
npm consumers resolve `types`/`import` → `dist` exactly as before.

### Fixed

- Type declarations now resolve under Node16/NodeNext: the `types` conditions
  match the real `tsc` `.d.ts` emit, and declaration files carry explicit
  `.js` import extensions.
- The built CLI no longer crashes with `superclass is not a constructor` —
  `@skaile/workspaces/*` self-imports are bundled into the output instead of
  left as external runtime imports that resolved cyclically across the
  chunk/`dist` boundary.

### Changed

- The dev/publish `exports` swap is collapsed into a single conditional
  `exports` map. The `skaile-src` condition resolves to TypeScript source for
  in-repo tooling; every other consumer falls through to `types`/`import`.

## 0.8.0 — 2026-05-15

Initial public release.

`@skaile/workspaces` collapses the former 26-package `workspaces/` workspace
into a single published package. Every internal layer — `types`, `core`, `bridge`,
`runner`, `store`, `connectors`, `base-assets`, `resolver`, `transport`, `session`,
`library`, `secrets`, `plugins`, `discovery`, `telemetry`, `asset-manager`,
`workspace-plugin`, and the `sdk` facade — is reachable as a subpath export
(`@skaile/workspaces/<subpath>`).

The legacy unscoped-internal `@skaile/agent-*` package names were retired in this
release. See `docs/migration-v1-to-v2.md` and the publish-migration design spec
for the full consolidation history.
