Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,7 @@ colors communicate a successful or failed probe/migration result.

- Connector, Skill, and Specialist `name` values are stable invocation identities. They are fixed after creation and are used by host APIs, generated Skill documents, package references, and policy routing. Editing a presentation label must never change these references.
- `displayName` is presentation-only and may appear in lists, search results, prompts, and approval UI. Connector and Specialist editors may change it freely. A Skill may read an optional `displayName` from external `SKILL.md` frontmatter and falls back to `name`; built-in Skills and app-generated Skill exports omit that non-standard field, and the app does not maintain a separate Skill display-name field outside the manifest.
- Connector context follows a distinct derived path: after live tool discovery the app generates an on-demand `mcp-<name>/SKILL.md`. Its frontmatter identity and every `host.mcp` example use immutable `name`; `displayName` may appear only in generated prose (or through an explicit `listConnectors()` result). Updating a Connector regenerates this document and reloads Skills without creating an invocation alias. Auth-recovery guidance derived from Connector configuration and discovered login tools remains part of the generated document.
- Connector context follows a distinct derived path: after live tool discovery the app generates an on-demand `mcp-<name>/SKILL.md` under the rebuildable, versioned source `<configRoot>/runtime-support/connector-skills-v1`. This source is app-owned but is not a framework catalog; Connector refresh and cleanup never read, write, or delete the rollback directories under `claude/skills`, `opencode/config/opencode/skills`, `codex/skills`, or `codex-subscription/skills`. Agent runtime generations read custom Connector documents only from this derived source, while bundled Connector documents may be rendered directly into the private generation. Frontmatter identity and every `host.mcp` example use immutable `name`; `displayName` may appear only in generated prose (or through an explicit `listConnectors()` result). Updating a Connector regenerates the derived document and reloads Skills without creating an invocation alias. Auth-recovery guidance derived from Connector configuration and discovered login tools remains part of the generated document.
- A custom Connector also has an internal UUID `id`. Local Specialist capability references and durable permission grants use that UUID; runtime calls, generated Connector Skills, and portable package references use the immutable lowercase-hyphenated `name`. Package import/export resolves between the two through the live Connector catalog. The UI shows `displayName` and exposes the immutable Connector name separately. Display names and UUIDs are not invocation aliases.
- Connector template schema v1 stores both `name` and `displayName` directly. Export never includes secrets, and import does not synthesize compatibility aliases from a display name.
- Specialist package schema v1 remains byte-for-byte compatible in field shape: package `name` stays the immutable invocation identity and `displayName` stays editable presentation metadata. `skillIds` and `connectorIds` keep their released JSON keys but contain portable capability names; local Specialist persistence contains installation IDs. Featured Skills are exported as references and are never copied into `skills/`.
Expand All @@ -717,8 +717,9 @@ colors communicate a successful or failed probe/migration result.
- Directly copied Personal and Imported packages at `<configRoot>/skills/<source>/<name>/SKILL.md` use the same central catalog as Settings and every agent framework, where `<source>` is `personal` or `imported` and `<name>` is 1–64 lowercase letters or numbers separated by single hyphens. The shared agent system prompt supplies both absolute source paths: it may author a user-requested package in Personal, while Imported is informational and GitHub, attachment, search, preview, or confirmation sources remain on the application-owned import flow.
- `storageRoot` is the historical code name for this fixed, non-relocatable `configRoot`; it is not the user-selectable `dataRoot`. Personal and Imported source packages, Settings, and the app-owned agent profiles live below `configRoot`. `dataRoot` holds relocatable artifacts, notebooks, and rebuildable compute/runtime assets and does not participate in user-Skill discovery.
- Personal and Imported directories are the writable source of truth. The app scans those two sources into one central catalog; agent frameworks do not independently scan them in place. The user-Skill catalog observer watches `<configRoot>/skills`, coalesces bursts to at most one running and one pending reconciliation, and falls back to reconciliation every 30 seconds when recursive watching is unavailable. A catalog fingerprint change refreshes Settings and retires the current agent runtime generation. An active turn finishes against its existing generation, while every later turn resumes through a freshly provisioned generation.
- User-Skill compatibility hashing is incremental. A rebuildable index at `<configRoot>/runtime-support/user-skill-compatibility-v1.json` stores only relative package/file paths, file size and timestamps, and SHA-256 hashes; it never stores file contents or absolute paths. Unchanged files reuse their hashes, changed files are streamed through the hasher, deleted entries are pruned, and a missing or corrupt index is rebuilt from the Personal and Imported sources. The index lives outside `<configRoot>/skills`, so persisting it cannot trigger the catalog observer.
- Before an agent runtime starts, the enabled central catalog is copied into that framework's isolated, rebuildable Skill projection: `<configRoot>/claude/skills/os-<catalog-id>`, `<configRoot>/opencode/config/opencode/skills/os-<catalog-id>`, `<configRoot>/codex/skills/os-<catalog-id>`, or `<configRoot>/codex-subscription/skills/os-<catalog-id>`. The internal `os-<catalog-id>` directory is a projection identity, not the user package name. These projections are normalized and made read-only; stale app-owned projections are removed on synchronization, while the Personal/Imported source package remains authoritative.
- User-Skill compatibility hashing is incremental. A rebuildable v2 index at `<configRoot>/runtime-support/user-skill-compatibility-v2.json` stores only relative package/file paths, file size and timestamps, executable-bit state, and SHA-256 hashes; it never stores file contents or absolute paths. Content and normalized executable mode both participate in compatibility identity, so a chmod-only script change retires the current Agent runtime generation. Unchanged files reuse their hashes, changed files are streamed through the hasher, deleted entries are pruned, and a missing or corrupt index is rebuilt from the Personal and Imported sources. The separate legacy `user-skill-compatibility-v1.json` remains untouched for rollback applications. Both indexes live outside `<configRoot>/skills`, so persisting them cannot trigger the catalog observer.
- Stable framework catalogs under `<configRoot>/claude/skills`, `<configRoot>/opencode/config/opencode/skills`, `<configRoot>/codex/skills`, and `<configRoot>/codex-subscription/skills` are retained only for rollback applications. Current sessions neither discover nor update these legacy projections; the application does not delete them because an older release may still be using them.
- Agent-facing execution uses the only active current-version projection under `<configRoot>/runtime/agent-skills/v1/leases/<lease-id>/projection`. Every backend generation and subagent attempt receives a private, disposable projection plus cache and temporary directories in the same lease tree. Forks are reconstructed from an application-owned blueprint containing package source paths and validated content/mode metadata, not package bytes; only generated and override files are defensively cloned in memory. Package sources are checked against the acquired snapshot both before and after each copy, and the complete normalized projection is checked against the acquired revision before exposure. Releasing a lease deletes that exact tree; later acquisitions opportunistically remove crash leftovers without reading, sharing, or cleaning the legacy `catalogs/` tree. Read-only modes remain defense in depth rather than an immutability guarantee against deliberate same-user shell commands. Because leases share no projection tree, ordinary mutation through one lease's supplied paths cannot alter protected sources, another lease's files, or a later rebuilt generation; deliberate access to a separately enumerated same-user runtime path is outside that guarantee.
- Names beginning with `os-` or `mcp-`, names matching a bundled Featured/Internal Skill, Specialist sidecar IDs colliding with a bundled ID, and duplicate user sidecar IDs are logged and excluded so app-owned packages and user identities stay authoritative. Unsafe sidecar IDs are ignored. Existing newest-wins behavior remains for out-of-band Personal/Imported name duplicates; normal create/import flows prevent those collisions.
- List toolbar: a single row of `Select` source filter (`w-36`), a flex-1 search `Input` with a leading `Search` icon (`pl-8`, `type="search"`) and platform `Cmd/Ctrl+K` keycaps, then neutral **Manage** and **Add skill** controls.
- "Add skill" is a neutral (not primary) `DropdownMenu` trigger: `h-8 rounded-lg border border-border bg-card px-2.5 text-sm font-medium hover:bg-muted`, with a leading `Plus` and a trailing `ChevronDown` (`opacity-70`). Its items — Write from scratch, Upload a skill, Import from GitHub — use `gap-2.5`, a leading icon, and a stacked label + `text-xs text-muted-foreground` hint.
Expand Down
4 changes: 4 additions & 0 deletions scripts/ci/change-impact.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
"src/main/crash-diagnostics.ts",
"src/main/index.ts",
"src/main/lifecycle-shutdown.ts",
"src/main/local-fs/service.ts",
"src/main/local-rpc-transport.ts",
"src/main/notebook/environment-discovery.ts",
"src/main/notebook/environment-state-tracker.ts",
Expand Down Expand Up @@ -158,6 +159,9 @@
"src/main/settings/opencode-install.ts",
"src/main/settings/preferences.ts",
"src/main/settings/process-tree.ts",
"src/main/skills/agent-skill-runtime.ts",
"src/main/skills/agent-skill-runtime-environment.ts",
"src/main/skills/user-skill-compatibility-index.ts",
"src/main/settings/skill-catalog.ts",
"src/main/storage-root.ts",
"src/main/storage/**",
Expand Down
3 changes: 3 additions & 0 deletions scripts/ci/classify-pr-changes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,8 +328,11 @@ describe('pull request change classification', () => {
['session persistence', 'src/main/session-persistence/ipc.ts'],
['notebook shell process', 'src/main/notebook/shell-process.ts'],
['file save', 'src/main/file-save.ts'],
['local filesystem service', 'src/main/local-fs/service.ts'],
['specialist repository', 'src/main/specialist/repository.ts'],
['notebook runtime settings', 'src/main/settings/notebook-runtime-settings.ts'],
['Agent Skill runtime environment', 'src/main/skills/agent-skill-runtime-environment.ts'],
['User Skill compatibility index', 'src/main/skills/user-skill-compatibility-index.ts'],
['preferences', 'src/main/settings/preferences.ts']
])('adds native Windows lanes for %s changes', (_category, path) => {
const plan = classifyChanges([{ path, status: 'modified' }])
Expand Down
1 change: 1 addition & 0 deletions scripts/ci/module-impact.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@
},
"settings_backend_resolution": {
"ownerPaths": [
"src/main/settings/agent-skill-runtime-projection.ts",
"src/main/settings/backend-resolver.ts",
"src/main/settings/backend-selection-owner.ts",
"src/main/settings/backend-route-planner.ts",
Expand Down
37 changes: 35 additions & 2 deletions src/main/acp/agent-connection-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ describe('AcpAgentConnectionAdapter', () => {
const releaseBridge = vi.fn(async () => undefined)
const releaseAnthropic = vi.fn(async () => undefined)
const releaseProviderTransport = vi.fn(async () => undefined)
const releaseSkillRuntime = vi.fn(async () => undefined)
const backend: ResolvedAgentBackend = {
framework: { ...claudeCodeFramework, spawn: () => asAgentProcess(process) },
executablePath: '/bin/agent',
Expand All @@ -176,7 +177,8 @@ describe('AcpAgentConnectionAdapter', () => {
providerTransportLease: {
setTarget: vi.fn(() => true),
release: releaseProviderTransport
}
},
skillRuntimeLease: { release: releaseSkillRuntime }
}
const candidate = await openCandidate(process, backend)

Expand All @@ -188,6 +190,32 @@ describe('AcpAgentConnectionAdapter', () => {
expect(releaseBridge).toHaveBeenCalledOnce()
expect(releaseAnthropic).toHaveBeenCalledOnce()
expect(releaseProviderTransport).toHaveBeenCalledOnce()
expect(releaseSkillRuntime).toHaveBeenCalledOnce()
})

it('reports an untransferred Skill Runtime lease release failure at its cleanup stage', async () => {
const process = new FakeAgentProcess()
const failure = new Error('runtime release failed')
const connectionHooks = hooks()
const candidate = await openCandidate(
process,
{
framework: claudeCodeFramework,
executablePath: '',
env: {},
skillRuntimeLease: { release: vi.fn(async () => Promise.reject(failure)) }
},
connectionHooks
)

await candidate.dispose()

expect(connectionHooks.reportCleanupFailure).toHaveBeenCalledWith(
'skill-runtime-lease',
failure,
'claude-code',
1
)
})

it('rejects a transfer to a different owner epoch without consuming the candidate', async () => {
Expand All @@ -214,6 +242,7 @@ describe('AcpAgentConnectionAdapter', () => {
const releaseBridge = vi.fn(async () => undefined)
const releaseAnthropic = vi.fn(async () => undefined)
const releaseProviderTransport = vi.fn(async () => undefined)
const releaseSkillRuntime = vi.fn(async () => undefined)
const owner = new AcpConnectionResourceOwner()
let candidateDispose: (() => Promise<void>) | undefined
await owner.connect(async (attempt) => {
Expand All @@ -234,7 +263,8 @@ describe('AcpAgentConnectionAdapter', () => {
providerTransportLease: {
setTarget: vi.fn(() => true),
release: releaseProviderTransport
}
},
skillRuntimeLease: { release: releaseSkillRuntime }
})
candidateDispose = candidate.dispose
const transferred = candidate.transferTo(attempt)
Expand All @@ -259,6 +289,7 @@ describe('AcpAgentConnectionAdapter', () => {
expect(releaseBridge).not.toHaveBeenCalled()
expect(releaseAnthropic).not.toHaveBeenCalled()
expect(releaseProviderTransport).not.toHaveBeenCalled()
expect(releaseSkillRuntime).not.toHaveBeenCalled()
return attempt.publish({ close: false, delete: false, resume: false })
})

Expand All @@ -267,12 +298,14 @@ describe('AcpAgentConnectionAdapter', () => {
expect(releaseBridge).not.toHaveBeenCalled()
expect(releaseAnthropic).not.toHaveBeenCalled()
expect(releaseProviderTransport).not.toHaveBeenCalled()
expect(releaseSkillRuntime).not.toHaveBeenCalled()

await owner.teardown(owner.epoch)
expect(terminateProcessTree).toHaveBeenCalledOnce()
expect(releaseBridge).toHaveBeenCalledOnce()
expect(releaseAnthropic).toHaveBeenCalledOnce()
expect(releaseProviderTransport).toHaveBeenCalledOnce()
expect(releaseSkillRuntime).toHaveBeenCalledOnce()
})

it('retains cleanup ownership when the resource owner rejects transfer', async () => {
Expand Down
14 changes: 13 additions & 1 deletion src/main/acp/agent-connection-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ import { readWorkspaceTextFile, writeWorkspaceTextFile } from './filesystem'
type ResponsesBridgeLease = ResolvedAgentBackend['responsesBridgeLease']
type AnthropicBridgeLease = ResolvedAgentBackend['anthropicBridgeLease']
type ProviderTransportLease = ResolvedAgentBackend['providerTransportLease']
type SkillRuntimeLease = ResolvedAgentBackend['skillRuntimeLease']
type CandidateCleanupStage =
| 'connection'
| 'agent-process'
| 'bridge-lease'
| 'anthropic-bridge-lease'
| 'provider-transport-lease'
| 'skill-runtime-lease'
type AcpProcessEventContext = Readonly<{
process: ChildProcessWithoutNullStreams
framework: AgentFramework['id']
Expand Down Expand Up @@ -111,6 +113,7 @@ class AcpAgentConnectionAdapter {
let bridgeLease: ResponsesBridgeLease
let anthropicBridgeLease: AnthropicBridgeLease
let providerTransportLease: ProviderTransportLease
let skillRuntimeLease: SkillRuntimeLease
let backendAttempt: AcpBackendGenerationAttempt | undefined
let framework: AgentFramework['id'] = 'claude-code'

Expand Down Expand Up @@ -159,6 +162,13 @@ class AcpAgentConnectionAdapter {
reportCleanupFailure('provider-transport-lease', error)
}
}
if (skillRuntimeLease) {
try {
await skillRuntimeLease.release()
} catch (error) {
reportCleanupFailure('skill-runtime-lease', error)
}
}
}

try {
Expand All @@ -167,6 +177,7 @@ class AcpAgentConnectionAdapter {
bridgeLease = backend.responsesBridgeLease
anthropicBridgeLease = backend.anthropicBridgeLease
providerTransportLease = backend.providerTransportLease
skillRuntimeLease = backend.skillRuntimeLease
backendAttempt = input.prepareBackend(backend)
hooks.onBackendResolved(framework)
process = input.spawnAgent
Expand Down Expand Up @@ -238,7 +249,8 @@ class AcpAgentConnectionAdapter {
framework,
bridgeLease,
anthropicBridgeLease,
providerTransportLease
providerTransportLease,
skillRuntimeLease
})
state = 'transferred'
openedConnection.closed.then(() => {
Expand Down
Loading
Loading