diff --git a/.changeset/reconcile-dev-host-entries.md b/.changeset/reconcile-dev-host-entries.md new file mode 100644 index 000000000..9c4f45099 --- /dev/null +++ b/.changeset/reconcile-dev-host-entries.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Make `agent-bundle dev --install-host` remove stale manager-published entries while preserving neighboring host and user state, including rollback after a failed epoch publication. (#784) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 2044ee712..31fa12127 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1596,7 +1596,7 @@ host-facing build together with the failed checks. | --- | --- | --- | --- | | `AB7200` | error | A development rebuild could not be admitted: the coordinator is closed, closing, or not yet started. | Restart `agent-bundle dev`; no epoch changed. | | `AB7201` | error | The prepare, lint, or artifact phase of a development rebuild threw instead of reporting diagnostics. The message names the phase and the underlying error. | Fix the named failure and save again; the last-good epoch stays active. | -| `AB7202` | error | Synchronizing a new epoch into an installed development host (`claude`, `codex`, or `cursor`) failed during stable-source staging, host installation, an existing Codex app-server refresh, or direct publication. A failed direct publication rolls pointers back when a prior generation remains; every failure is published on `dev.host.sync`. | Repair the host cache path, permissions, or Codex app-server control connection named in the message; the next successful epoch re-syncs. | +| `AB7202` | error | Synchronizing a new epoch into an installed development host (`claude`, `codex`, or `cursor`) failed during stable-source staging, host installation, an existing Codex app-server refresh, or direct publication. A failed direct publication restores removed manager-owned entries and rolls pointers back when a prior generation remains; every failure is published on `dev.host.sync`. | Repair the host cache path, permissions, or Codex app-server control connection named in the message; the next successful epoch re-syncs. | | `AB7210` | error | `dev.contracts` is malformed, its `fixtures` module escapes the project root, cannot be loaded, or default-exports something other than route-id keyed `ContractRouteFixture` objects. Reported on `dev.contract.status` for the affected epoch; compilation is unaffected. | Correct `dev.contracts` or the fixture module and rebuild; host surfaces keep the last passing epoch meanwhile. | | `AB7211` | error | The development contract matrix failed or could not complete for a published epoch. The message carries the aggregated `contract-violation` detail; `dev.contract.status` lists the failed check names grouped by route. That epoch is never adopted by live host connections or development installs. | Fix the failing route or fixture and rebuild; a passing epoch is adopted normally. | | `AB8024` | error (MCP) | The epoch a live host connection was serving vanished from the epoch store mid-session. The connection is invalidated and the typed MCP error carries `{ code, epochId }`. | Reconnect from the host; the proxy binds to the currently adopted epoch. | diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 5a5f4ea39..efdbaebd3 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -430,8 +430,10 @@ the host ever seeing a disconnect: installs a marked development variant through the ordinary installer once, pointing the host's MCP document at the proxy, then re-syncs hooks, Skills, and MCP Apps into the host's own layout on every adopted epoch with atomic - generation swaps and rollback (`AB7202`). Hooks are spawned per event, so - they pick up the new epoch on their next invocation. + generation swaps and rollback (`AB7202`). Re-sync removes only top-level + entries recorded as manager-published; neighboring host or user entries are + never inferred to be owned. Hooks are spawned per event, so they pick up the + new epoch on their next invocation. 3. **A contract gate on adoption.** Declaring `dev.contracts` in `agent-bundle.config.ts` runs the generated contract matrix against each published epoch through an epoch-pinned generated stdio session before any diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index b9344a7b7..3bf5db550 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -405,11 +405,13 @@ not affect whether the project-local framework can be spawned. Each later `artifact.available` event copies the new target into an immutable installed generation. Top-level directories switch by atomic symlink (or Windows junction) rename and top-level files by -atomic sibling-file rename, so a host sees an old or new complete entry and no synchronized -directory disappears between generations. A failed publication rolls pointers back to the prior -generation and emits an `AB7202` diagnostic on `dev.host.sync`; a failed build emits no -`artifact.available`, so the last-good install is unchanged. Re-sync writes the host cache directly -and does not invoke the Claude or Codex CLI again. +atomic sibling-file rename, so a host sees each retained entry as an old or new complete value. +Re-sync records top-level ownership under `.agent-bundle-dev/` and removes only manager-published +entries the new epoch no longer contains, leaving neighboring host and user state untouched. A +failed publication restores removed entries and rolls pointers back to the prior generation before +emitting an `AB7202` diagnostic on `dev.host.sync`; a failed build emits no `artifact.available`, so +the last-good install is unchanged. Re-sync writes the host cache directly and does not invoke the +Claude or Codex CLI again. Stopping the dev server leaves the marked development install in place. Hooks and Skills remain on disk, while the stable proxy command fails closed until that project dev server is running again. diff --git a/packages/agent-bundle/src/dev/host-install-manager.ts b/packages/agent-bundle/src/dev/host-install-manager.ts index 38ea05979..e257073c7 100644 --- a/packages/agent-bundle/src/dev/host-install-manager.ts +++ b/packages/agent-bundle/src/dev/host-install-manager.ts @@ -3,10 +3,12 @@ import { lstat, mkdir, mkdtemp, + readFile, readdir, rename, rm, symlink, + writeFile, } from 'node:fs/promises'; import { homedir, tmpdir } from 'node:os'; import { basename, join, relative, resolve } from 'node:path'; @@ -16,6 +18,8 @@ import { Effect, FileSystem } from 'effect'; import { readArtifactManifest } from '../build/manifest-file.ts'; import { reindexArtifactManifest } from '../build/manifest-reindex.ts'; import { stableJson } from '../core/digest.ts'; +import { isErrno } from '../core/errors.ts'; +import { isPortablePathSegment } from '../core/paths.ts'; import { isPlatformErrno, readFileString, type PlatformRun } from '../effect/platform.ts'; import { platformRunOf } from './platform-run.ts'; import type { DevPlatformRuntime } from './platform-runtime.ts'; @@ -43,6 +47,7 @@ import type { EpochReference, EpochStore } from './epoch-store.ts'; import type { ProjectEventHub, ProjectEventSubscription } from './events.ts'; export const DEV_INSTALL_MARKER = '.agent-bundle-dev.json'; +const DEV_INSTALL_STATE = '.agent-bundle-dev'; interface EpochReferenceSource { acquireEpochReference(epochId: string): Promise>; @@ -76,6 +81,12 @@ interface DevInstallMarker { readonly schemaVersion: 1; } +interface PublishedEntriesManifest { + readonly entries: readonly string[]; + readonly epochId: string; + readonly schemaVersion: 1; +} + const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); @@ -222,7 +233,75 @@ const pathExists = async (path: string): Promise => { }; const generationRoot = (destination: string, epochId: string): string => - join(destination, '.agent-bundle-dev', 'generations', epochId); + join(destination, DEV_INSTALL_STATE, 'generations', epochId); + +const publishedEntriesPath = (destination: string): string => + join(destination, DEV_INSTALL_STATE, 'published.json'); + +const entryNames = async (root: string): Promise => { + const names: string[] = []; + for (const entry of await readdir(root, { withFileTypes: true })) { + if (!entry.isDirectory() && !entry.isFile()) { + throw new TypeError(`Development bundle entry ${JSON.stringify(entry.name)} is not a regular file or directory.`); + } + if (entry.name === DEV_INSTALL_STATE) { + throw new TypeError(`Development bundle entry ${JSON.stringify(entry.name)} is reserved for manager state.`); + } + names.push(entry.name); + } + return names.sort((left, right) => left.localeCompare(right)); +}; + +const readPublishedEntries = async (destination: string): Promise => { + const path = publishedEntriesPath(destination); + let document: unknown; + try { + if (!(await lstat(path)).isFile()) { + throw new TypeError(`Development published-entries manifest ${JSON.stringify(path)} is not a regular file.`); + } + document = JSON.parse(await readFile(path, 'utf8')) as unknown; + } catch (error) { + if (isErrno(error, 'ENOENT') || error instanceof SyntaxError) return undefined; + throw error; + } + if ( + !isRecord(document) || + document.schemaVersion !== 1 || + typeof document.epochId !== 'string' || + !isPortablePathSegment(document.epochId) || + !Array.isArray(document.entries) || + !document.entries.every( + (entry) => typeof entry === 'string' && entry !== DEV_INSTALL_STATE && isPortablePathSegment(entry), + ) || + new Set(document.entries).size !== document.entries.length + ) { + return undefined; + } + return Object.freeze({ + entries: Object.freeze([...document.entries]), + epochId: document.epochId, + schemaVersion: 1, + }); +}; + +const writePublishedEntries = async ( + destination: string, + epochId: string, + entries: readonly string[], +): Promise => { + const path = publishedEntriesPath(destination); + const temporary = `${path}.${process.pid}-${crypto.randomUUID()}.tmp`; + await mkdir(join(destination, DEV_INSTALL_STATE), { recursive: true }); + try { + await writeFile(temporary, `${stableJson({ entries, epochId, schemaVersion: 1 })}\n`, { + flag: 'wx', + mode: 0o600, + }); + await rename(temporary, path); + } finally { + await rm(temporary, { force: true }); + } +}; const installGeneration = async ( destination: string, @@ -294,6 +373,7 @@ const publishFile = async ( const publishInstalledGeneration = async ( destination: string, epochId: string, + published: string[] = [], ): Promise => { const generation = generationRoot(destination, epochId); const entries = await readdir(generation, { withFileTypes: true }); @@ -305,23 +385,89 @@ const publishInstalledGeneration = async ( } else { throw new TypeError(`Development bundle entry ${JSON.stringify(entry.name)} is not a regular file or directory.`); } + published.push(entry.name); } }; -const publishDevGeneration = async ( +const reconcilePublishedEntries = async ( destination: string, - bundleRoot: string, + previous: PublishedEntriesManifest | undefined, epochId: string, + nextEntries: readonly string[], + publish: ((published: string[]) => Promise) | undefined, + afterManifest?: () => Promise, ): Promise => { - await installGeneration(destination, bundleRoot, epochId); - await publishInstalledGeneration(destination, epochId); + const previousEntries = new Set(previous?.entries ?? []); + const stale = [...previousEntries].filter((entry) => !nextEntries.includes(entry)); + const previousGeneration = previous === undefined ? undefined : generationRoot(destination, previous.epochId); + const canRepublishPrevious = previousGeneration !== undefined && await pathExists(previousGeneration); + const needsBackup = !canRepublishPrevious && ( + publish === undefined ? stale.length > 0 : previousEntries.size > 0 + ); + const backup = needsBackup + ? join(destination, DEV_INSTALL_STATE, `rollback-${process.pid}-${crypto.randomUUID()}`) + : undefined; + const backupEntries = backup === undefined + ? [] + : publish === undefined ? stale : [...previousEntries]; + if (backup !== undefined) await mkdir(backup, { recursive: true }); + const published: string[] = []; + let manifestPublished = false; + try { + for (const entry of canRepublishPrevious ? stale : backupEntries) { + const path = join(destination, entry); + if (backup === undefined) { + await rm(path, { force: true, recursive: true }); + } else { + try { + await rename(path, join(backup, entry)); + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + } + } + } + await publish?.(published); + await writePublishedEntries(destination, epochId, nextEntries); + manifestPublished = true; + await afterManifest?.(); + } catch (error) { + try { + for (const entry of published) { + if (backup !== undefined || !previousEntries.has(entry)) { + await rm(join(destination, entry), { force: true, recursive: true }); + } + } + if (canRepublishPrevious && previous !== undefined) { + await publishInstalledGeneration(destination, previous.epochId); + } else if (backup !== undefined) { + for (const entry of backupEntries) { + const path = join(backup, entry); + if (await pathExists(path)) await rename(path, join(destination, entry)); + } + } + if (manifestPublished) { + if (previous === undefined) { + await rm(publishedEntriesPath(destination), { force: true }); + } else { + await writePublishedEntries(destination, previous.epochId, previous.entries); + } + } + if (backup !== undefined) await rm(backup, { force: true, recursive: true }); + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], 'Failed to roll back development host publication.', { + cause: rollbackError, + }); + } + throw error; + } + if (backup !== undefined) await rm(backup, { force: true, recursive: true }).catch(() => undefined); }; const pruneGenerations = async ( destination: string, retainedEpochIds: readonly string[], ): Promise => { - const root = join(destination, '.agent-bundle-dev', 'generations'); + const root = join(destination, DEV_INSTALL_STATE, 'generations'); const retained = new Set(retainedEpochIds); for (const entry of await readdir(root, { withFileTypes: true })) { if (entry.isDirectory() && !retained.has(entry.name)) { @@ -507,6 +653,8 @@ export class DevHostInstallManager { this.#installed.set(host, installed); } const previousEpochId = installed.epochId; + const previousPublished = await readPublishedEntries(installed.destination); + const nextEntries = await entryNames(prepared.root); let generationPublished = false; try { let refreshedByAppServer = false; @@ -519,16 +667,32 @@ export class DevHostInstallManager { if (plugin === undefined || marketplaceDocument === undefined) { throw new TypeError('Cannot refresh a Codex development install with no plugin marketplace identity.'); } - await request('plugin/install', { - marketplacePath: join(source, marketplaceDocument), - pluginName: plugin, - }); + await reconcilePublishedEntries( + installed.destination, + previousPublished, + epochId, + nextEntries, + undefined, + async () => { + await request('plugin/install', { + marketplacePath: join(source, marketplaceDocument), + pluginName: plugin, + }); + }, + ); return true; }, ) === true; } if (!refreshedByAppServer) { - await publishDevGeneration(installed.destination, prepared.root, epochId); + await installGeneration(installed.destination, prepared.root, epochId); + await reconcilePublishedEntries( + installed.destination, + previousPublished, + epochId, + nextEntries, + (published) => publishInstalledGeneration(installed.destination, epochId, published), + ); generationPublished = true; } } catch (error) { diff --git a/packages/agent-bundle/tests/dev-host-install-manager.test.ts b/packages/agent-bundle/tests/dev-host-install-manager.test.ts new file mode 100644 index 000000000..64f6b75eb --- /dev/null +++ b/packages/agent-bundle/tests/dev-host-install-manager.test.ts @@ -0,0 +1,174 @@ +import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { expect, it, rs } from '@rstest/core'; + +import * as codexAppServer from '../src/dev/codex-app-server.ts'; +import { ProjectEventHub } from '../src/dev/events.ts'; +import { DevHostInstallManager } from '../src/dev/host-install-manager.ts'; +import type { ArtifactEpoch } from '../src/dev/types.ts'; +import { writeInstallFixtureManifest } from './support/install-fixture.ts'; + +it('removes app-server-managed entries on the first Codex filesystem fallback', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-host-fallback-')); + const destination = join(root, 'installed'); + const stableSource = join(root, '.agent-bundle/dev/codex'); + const appServer = rs.spyOn(codexAppServer, 'withCodexAppServer'); + const refresh = async ( + _codexRoot: string, + action: Parameters[1], + ) => action(async () => { + await cp(stableSource, destination, { recursive: true }); + return undefined as never; + }); + appServer.mockImplementationOnce(refresh).mockImplementationOnce(refresh).mockResolvedValue(undefined); + const manager = new DevHostInstallManager({ + epochStore: { + acquireEpochReference: async (id) => ({ + close: async () => undefined, + epoch: { + configDigest: 'config', createdAt: '2026-09-08T00:00:00.000Z', + diagnostics: { errors: 0, infos: 0, warnings: 0 }, id, + manifestPath: join(root, id, 'agent-bundle.manifest.json'), + modelDigest: 'model', projectRevision: id, targetDigests: { codex: id }, + } satisfies ArtifactEpoch, + root: join(root, id), + }), + }, + environment: { CODEX_HOME: join(root, 'home') }, + eventHub: new ProjectEventHub(), + home: join(root, 'home'), + hosts: ['codex'], + installBundle: async (options) => { + await cp(options.from, destination, { recursive: true }); + return { + bundleRoot: options.from, destination, host: 'codex', plugin: 'probe', + state: 'installed', version: '1.0.0', + }; + }, + projectRoot: root, + uninstallBundle: async () => undefined, + }); + try { + for (const [id, files] of [ + ['epoch-1', []], + ['epoch-2', ['skills/probe/SKILL.md', 'old.txt']], + ['epoch-failed', ['aaa-new/probe.md', 'blocked.txt']], + ['epoch-3', ['commands/probe.md']], + ] as const) { + const source = join(root, id); + for (const file of ['.codex-plugin/plugin.json', '.agents/plugins/marketplace.json', ...files]) { + await mkdir(dirname(join(source, file)), { recursive: true }); + await writeFile(join(source, file), file.endsWith('.json') ? '{"name":"probe","version":"1.0.0"}' : id); + } + await writeInstallFixtureManifest(source, { name: 'probe', version: '1.0.0' }, [{ host: 'codex' }]); + } + for (const id of ['epoch-1', 'epoch-2']) { + manager.sync(id); + await manager.settled(); + expect(manager.attached('codex')?.epochId).toBe(id); + } + expect(await readFile(join(destination, 'skills/probe/SKILL.md'), 'utf8')).toBe('epoch-2'); + await expect(lstat(join(destination, '.agent-bundle-dev/generations'))).rejects.toMatchObject({ code: 'ENOENT' }); + await writeFile(join(destination, 'host-receipt.json'), 'keep file'); + await mkdir(join(destination, 'blocked.txt')); + await writeFile(join(destination, 'blocked.txt/keep'), 'unmanaged collision'); + manager.sync('epoch-failed'); + await manager.settled(); + expect(manager.attached('codex')?.epochId).toBe('epoch-2'); + expect(await readFile(join(destination, 'skills/probe/SKILL.md'), 'utf8')).toBe('epoch-2'); + expect(await readFile(join(destination, 'old.txt'), 'utf8')).toBe('epoch-2'); + await expect(lstat(join(destination, 'aaa-new'))).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readFile(join(destination, 'blocked.txt/keep'), 'utf8')).toBe('unmanaged collision'); + manager.sync('epoch-3'); + await manager.settled(); + expect(manager.attached('codex')?.epochId).toBe('epoch-3'); + await expect(lstat(join(destination, 'skills'))).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(lstat(join(destination, 'old.txt'))).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readFile(join(destination, 'commands/probe.md'), 'utf8')).toBe('epoch-3'); + expect(await readFile(join(destination, 'host-receipt.json'), 'utf8')).toBe('keep file'); + } finally { + await manager.close(); + appServer.mockRestore(); + await rm(root, { force: true, recursive: true }); + } +}); + +it('reconciles removed generation entries without deleting unmanaged installation state, including rollback', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-host-generations-')); + const destination = join(root, 'installed'); + const eventHub = new ProjectEventHub(); + const events: unknown[] = []; + eventHub.subscribe((event) => { if (event.type === 'dev.host.sync') events.push(event.payload); }); + const manager = new DevHostInstallManager({ + epochStore: { + acquireEpochReference: async (id) => ({ + close: async () => undefined, + epoch: { + configDigest: 'config', createdAt: '2026-09-08T00:00:00.000Z', + diagnostics: { errors: 0, infos: 0, warnings: 0 }, id, + manifestPath: join(root, id, 'agent-bundle.manifest.json'), + modelDigest: 'model', projectRevision: id, targetDigests: { cursor: id }, + } satisfies ArtifactEpoch, + root: join(root, id), + }), + }, + eventHub, + home: join(root, 'home'), + hosts: ['cursor'], + installBundle: async (options) => { + await cp(options.from, destination, { recursive: true }); + return { + bundleRoot: options.from, destination, host: 'cursor', plugin: 'probe', + state: 'installed', version: '1.0.0', + }; + }, + projectRoot: root, + }); + try { + for (const [id, files] of [ + ['epoch-1', ['skills/probe/SKILL.md', 'old.txt']], + ['epoch-2', ['commands/probe.md']], + ['epoch-3', ['commands/probe.md']], + ['epoch-failed', ['aaa-new/probe.md', 'blocked.txt']], + ] as const) { + const source = join(root, id); + for (const file of ['.cursor-plugin/plugin.json', ...files]) { + await mkdir(dirname(join(source, file)), { recursive: true }); + await writeFile(join(source, file), file.endsWith('.json') ? '{"name":"probe","version":"1.0.0"}' : id); + } + await writeInstallFixtureManifest(source, { name: 'probe', version: '1.0.0' }, [{ host: 'cursor' }]); + } + manager.sync('epoch-1'); + await manager.settled(); + expect(await readFile(join(destination, 'skills/probe/SKILL.md'), 'utf8')).toBe('epoch-1'); + await mkdir(join(destination, 'host-state')); + await writeFile(join(destination, 'host-state/settings.json'), 'keep directory'); + await writeFile(join(destination, 'host-receipt.json'), 'keep file'); + + for (const id of ['epoch-2', 'epoch-3']) { + manager.sync(id); + await manager.settled(); + expect(manager.attached('cursor')?.epochId).toBe(id); + await expect(lstat(join(destination, 'skills'))).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(lstat(join(destination, 'old.txt'))).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readFile(join(destination, 'commands/probe.md'), 'utf8')).toBe(id); + } + expect((await readdir(join(destination, '.agent-bundle-dev/generations'))).sort()).toEqual(['epoch-2', 'epoch-3']); + await mkdir(join(destination, 'blocked.txt')); + await writeFile(join(destination, 'blocked.txt/keep'), 'unmanaged collision'); + manager.sync('epoch-failed'); + await manager.settled(); + expect(events).toEqual(expect.arrayContaining([expect.objectContaining({ epochId: 'epoch-failed', state: 'failed' })])); + expect(manager.attached('cursor')?.epochId).toBe('epoch-3'); + await expect(lstat(join(destination, 'aaa-new'))).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readFile(join(destination, 'commands/probe.md'), 'utf8')).toBe('epoch-3'); + expect(await readFile(join(destination, 'blocked.txt/keep'), 'utf8')).toBe('unmanaged collision'); + expect(await readFile(join(destination, 'host-state/settings.json'), 'utf8')).toBe('keep directory'); + expect(await readFile(join(destination, 'host-receipt.json'), 'utf8')).toBe('keep file'); + } finally { + await manager.close(); + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/website/docs/en/guide/development/workbench.mdx b/website/docs/en/guide/development/workbench.mdx index 430866e73..22cec6460 100644 --- a/website/docs/en/guide/development/workbench.mdx +++ b/website/docs/en/guide/development/workbench.mdx @@ -488,11 +488,12 @@ Every selected host installs from the same composite epoch root. Outside the run app-server path, each later `artifact.available` event copies that root into an immutable installed generation for each host. Top-level directories switch by atomic symlink (or Windows junction) rename and top-level files by atomic sibling-file rename, so a host sees an old or a new complete -entry and no synchronized directory disappears between generations. A failed direct publication -rolls the pointers back when a prior generation remains and emits an `AB7202` diagnostic on -`dev.host.sync`; a failed build emits no `artifact.available` at all, so the last-good install is -untouched. Re-sync writes the host cache directly and does not invoke the Claude or Codex CLI -again. +entry. After the first direct publication, no synchronized directory disappears between generations. +A failed direct publication rolls the pointers back when a prior generation remains and emits an +`AB7202` diagnostic on `dev.host.sync`; a failed build emits no `artifact.available` at all, so the last-good install is +untouched. Re-sync records top-level ownership under `.agent-bundle-dev/` and removes only entries +recorded as manager-published; neighboring host or user entries are never inferred to be owned. +Re-sync writes the host cache directly and does not invoke the Claude or Codex CLI again. Stopping the dev server unregisters and removes its Claude and Codex development installs. A later run registers the same stable source path again, so restart does not leave duplicate diff --git a/website/docs/zh/guide/development/workbench.mdx b/website/docs/zh/guide/development/workbench.mdx index fd334e6aa..e01cbf957 100644 --- a/website/docs/zh/guide/development/workbench.mdx +++ b/website/docs/zh/guide/development/workbench.mdx @@ -408,10 +408,11 @@ agent-bundle dev proxy --root --server --target