paseo-room 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,9 +64,12 @@ starts, its exit code is preserved; a signal is returned using the conventional
64
64
  ## Requirements
65
65
 
66
66
  - Node 22 or newer, macOS or Linux.
67
- - A running Paseo daemon with CLI and daemon on the same version. Codex and Claude require
68
- **0.8.0-beta.1 or newer**; any selection containing Pi requires **0.8.0 or newer**.
69
- `paseo-room` checks this before touching anything, and never installs or upgrades Paseo.
67
+ - A running Paseo daemon with CLI and daemon on the same version. Codex requires
68
+ **0.8.0-beta.1 or newer**; any selection containing Claude or Pi requires **0.8.0 or newer**.
69
+ Claude also requires Paseo plugins to be enabled explicitly: the generated trusted server plugin
70
+ is the strong contract carrier, and `paseo-room` never enables plugins for you. The plugin is
71
+ version-bounded to `>=0.8.0 <0.9.0`. `paseo-room` checks compatibility before touching anything,
72
+ and never installs or upgrades Paseo.
70
73
  - An initialised Codex home (`~/.codex/config.toml`) and/or Claude Code home (`~/.claude`).
71
74
  Codex must be new enough for `codex debug models` to print its JSON model catalog: the room
72
75
  seats Codex only if it can generate the scrubbed catalog copy.
@@ -86,6 +89,9 @@ starts, its exit code is preserved; a signal is returned using the conventional
86
89
  room.json # what this CLI created; verify and remove read it
87
90
  AUTHENTICATION.md # exact per-role login commands; contains no secrets
88
91
  room/WORKSPACE_PROTOCOL.md # the default protocol every seat carries, as one readable file
92
+ plugin/ # Claude only: trusted creation-time system-prompt append carrier
93
+ paseo-plugin.json # accepts Paseo >=0.8.0 <0.9.0
94
+ index.server.ts, server/ # exact provider map + generated role contracts
89
95
  roles/codex/<role>/
90
96
  config.toml # your config.toml + the room's overrides
91
97
  role-instructions.md # readable copy of what this seat was told
@@ -237,18 +243,20 @@ holding older contract text.
237
243
 
238
244
  **The room never replaces an agent's base prompt.** Codex's `model_instructions_file`,
239
245
  Claude's `--system-prompt` and Pi's `SYSTEM.md` / `--system-prompt` all *replace* the vendor
240
- system prompt; using them would mean
241
- vendoring a full copy of that prompt and re-vendoring it on every agent release. The role
242
- contract is additive instead: `developer_instructions` for Codex, `CLAUDE.md` for Claude,
243
- and the generated Pi `APPEND_SYSTEM.md` passed with `--append-system-prompt` for Pi. Pi keeps
244
- normal project `AGENTS.md` / `CLAUDE.md` context loading.
245
-
246
- Claude's carrier is the weakest of the three, and knowingly so. `CLAUDE.md` is user memory,
247
- which a project-level `CLAUDE.md` can dilute, and Paseo runs Claude through the Claude Agent
248
- SDK rather than as a plain CLI process — so there is no provider-owned command line to append
249
- a stronger prompt through, and no CLI flag is proposed here. The real fix is a
250
- provider-owned SDK append field in Paseo itself. Until that exists, the room keeps the
251
- contract short and states the limit instead of implying parity with Codex.
246
+ system prompt; using them would mean vendoring a full copy of that prompt and re-vendoring it
247
+ on every agent release. The role contract is additive instead: `developer_instructions` for
248
+ Codex, a room-owned Paseo creation hook that writes `config.systemPrompt` for Claude, and the
249
+ generated Pi `APPEND_SYSTEM.md` passed with `--append-system-prompt` for Pi. Pi keeps normal
250
+ project `AGENTS.md` / `CLAUDE.md` context loading.
251
+
252
+ For Claude, Paseo maps `config.systemPrompt` to the Claude Code preset's SDK `append` field,
253
+ so the native prompt remains intact while the generated role contract enters the instruction
254
+ layer. The trusted plugin targets only exact `claude-supervisor`, `claude-lead`, and
255
+ `claude-peer` room providers and skips internal agents; existing caller prompt text is
256
+ preserved. `CLAUDE.md` remains unchanged as a degraded and resume fallback. `verify` fails if
257
+ the plugin is absent, disabled, failed, registered from another path, or drifted, because a
258
+ silently missing carrier is not a guarantee. The hook runs for newly created sessions;
259
+ recreate an existing Claude session after setup or an update.
252
260
 
253
261
  Pi providers use a strict command tail:
254
262
 
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ var init_package = __esm({
15
15
  "package.json"() {
16
16
  package_default = {
17
17
  name: "paseo-room",
18
- version: "0.1.0",
18
+ version: "0.2.0",
19
19
  description: "One CLI that configures Codex, Claude Code, and Pi role homes in $HOME and registers them with your local Paseo daemon.",
20
20
  keywords: [
21
21
  "paseo",
@@ -49,7 +49,7 @@ var init_package = __esm({
49
49
  lint: "eslint . --max-warnings 0",
50
50
  test: "vitest run",
51
51
  "test:package": "vitest run --config vitest.package.config.ts",
52
- build: `tsup && node --input-type=module -e "import { cpSync, rmSync } from 'node:fs'; rmSync('dist/prompts', { recursive: true, force: true }); cpSync('src/room/prompts', 'dist/prompts', { recursive: true });"`,
52
+ build: `tsup && node --input-type=module -e "import { cpSync, rmSync } from 'node:fs'; for (const [source, target] of [['src/room/prompts', 'dist/prompts'], ['src/plugin-assets', 'dist/plugin-assets']]) { rmSync(target, { recursive: true, force: true }); cpSync(source, target, { recursive: true }); }"`,
53
53
  prepack: "npm run build",
54
54
  verify: "npm run typecheck && npm run lint && npm test && npm run build && npm run test:package"
55
55
  },
@@ -1868,7 +1868,7 @@ var init_pi = __esm({
1868
1868
  import { gte, valid } from "semver";
1869
1869
  import { z as z2 } from "zod";
1870
1870
  function minimumPaseoVersion(agents) {
1871
- return agents.includes("pi") ? PI_MINIMUM_VERSION : MINIMUM_VERSION;
1871
+ return agents.includes("pi") || agents.includes("claude") ? PI_MINIMUM_VERSION : MINIMUM_VERSION;
1872
1872
  }
1873
1873
  function normalizeUrl(listen) {
1874
1874
  const match = /^(?:ws:\/\/)?([^:/]+|\[[^\]]+\]):(\d+)$/.exec(listen.trim());
@@ -1929,15 +1929,38 @@ async function withSession(daemon, run2, options = {}) {
1929
1929
  }, warn() {
1930
1930
  }, error() {
1931
1931
  } };
1932
- const factory = options.factory ?? (await import("@getpaseo/client")).createPaseoClient;
1933
- const client = factory({
1932
+ const clientConfig = {
1934
1933
  url: `${daemon.url}/ws`,
1935
1934
  ...options.password === void 0 ? {} : { password: options.password },
1936
1935
  appVersion: MINIMUM_VERSION,
1937
1936
  connectTimeoutMs: 1e4,
1938
1937
  reconnect: { enabled: false },
1939
1938
  logger: quiet
1940
- });
1939
+ };
1940
+ let client;
1941
+ if (options.factory !== void 0) {
1942
+ client = options.factory(clientConfig);
1943
+ } else {
1944
+ const [{ createPaseoApi }, { DaemonClient }] = await Promise.all([
1945
+ import("@getpaseo/client"),
1946
+ import("@getpaseo/client/internal/daemon-client")
1947
+ ]);
1948
+ const raw = new DaemonClient({ ...clientConfig, clientId: `paseo-room-${String(process.pid)}`, clientType: "cli" });
1949
+ client = {
1950
+ ...createPaseoApi(raw),
1951
+ connect: () => raw.connect(),
1952
+ close: () => raw.close(),
1953
+ ensureConnected: () => {
1954
+ raw.ensureConnected();
1955
+ },
1956
+ getConnectionState: () => raw.getConnectionState(),
1957
+ listPlugins: () => raw.listPlugins(),
1958
+ installDirectoryPlugin: (path, id) => raw.installDirectoryPlugin(path, id),
1959
+ reloadPlugin: (id) => raw.reloadPlugin(id),
1960
+ enablePlugin: (id) => raw.enablePlugin(id),
1961
+ removePlugin: (id) => raw.removePlugin(id)
1962
+ };
1963
+ }
1941
1964
  try {
1942
1965
  await client.connect();
1943
1966
  return await run2({
@@ -1958,6 +1981,25 @@ async function withSession(daemon, run2, options = {}) {
1958
1981
  async writeProfiles(profiles) {
1959
1982
  await client.config.patch({ agentProfiles: [...profiles] });
1960
1983
  },
1984
+ async pluginsEnabled() {
1985
+ const response = configSchema.parse(await client.config.get());
1986
+ return response.config.pluginsEnabled === true;
1987
+ },
1988
+ async listPlugins() {
1989
+ return await client.listPlugins();
1990
+ },
1991
+ async installPlugin(path, id) {
1992
+ return await client.installDirectoryPlugin(path, id);
1993
+ },
1994
+ async reloadPlugin(id) {
1995
+ return await client.reloadPlugin(id);
1996
+ },
1997
+ async enablePlugin(id) {
1998
+ return await client.enablePlugin(id);
1999
+ },
2000
+ async removePlugin(id) {
2001
+ await client.removePlugin(id);
2002
+ },
1961
2003
  async refresh(ids) {
1962
2004
  await client.providers.refresh({ providers: [...ids] });
1963
2005
  }
@@ -2024,14 +2066,59 @@ var init_paseo = __esm({
2024
2066
  config: z2.object({
2025
2067
  providers: z2.record(z2.string(), z2.unknown()).optional(),
2026
2068
  // Read loosely: entries carry operator fields the room neither knows nor touches.
2027
- agentProfiles: z2.array(z2.record(z2.string(), z2.unknown())).optional()
2069
+ agentProfiles: z2.array(z2.record(z2.string(), z2.unknown())).optional(),
2070
+ pluginsEnabled: z2.boolean().optional()
2028
2071
  })
2029
2072
  });
2030
2073
  }
2031
2074
  });
2032
2075
 
2076
+ // src/plugin.ts
2077
+ import { readFileSync as readFileSync2 } from "fs";
2078
+ import { join as join10 } from "path";
2079
+ function loadAsset(path) {
2080
+ return readFileSync2(new URL(`./plugin-assets/${path}`, import.meta.url), "utf8");
2081
+ }
2082
+ function claudeCarrierPluginDir(layout) {
2083
+ return join10(layout.roomHome, "plugin");
2084
+ }
2085
+ function renderClaudeCarrierContract(roles) {
2086
+ const contracts = Object.fromEntries(roles.map((role) => [providerId("claude", role), renderInstructions(role)]));
2087
+ return [
2088
+ "/** Generated by paseo-room. The Markdown prompt assets remain authoritative. */",
2089
+ `export const GENERATION = ${JSON.stringify(contractDigest())};`,
2090
+ `export const CONTRACT: Readonly<Record<string, string>> = ${JSON.stringify(contracts, null, 2)};`,
2091
+ ""
2092
+ ].join("\n");
2093
+ }
2094
+ function claudeCarrierEntries(layout, roles) {
2095
+ const root = claudeCarrierPluginDir(layout);
2096
+ return [
2097
+ { kind: "dir", path: root },
2098
+ { kind: "dir", path: join10(root, "server") },
2099
+ ...STATIC_ASSETS.map((path) => ({ kind: "file", path: join10(root, path), content: loadAsset(path) })),
2100
+ { kind: "file", path: join10(root, "server", "contract.ts"), content: renderClaudeCarrierContract(roles) }
2101
+ ];
2102
+ }
2103
+ var CLAUDE_CARRIER_PLUGIN_ID, STATIC_ASSETS;
2104
+ var init_plugin = __esm({
2105
+ "src/plugin.ts"() {
2106
+ "use strict";
2107
+ init_roles();
2108
+ init_instructions();
2109
+ CLAUDE_CARRIER_PLUGIN_ID = "paseo-room-claude-carrier";
2110
+ STATIC_ASSETS = [
2111
+ "paseo-plugin.json",
2112
+ "package.json",
2113
+ "tsconfig.json",
2114
+ "index.server.ts",
2115
+ "server/carrier.ts"
2116
+ ];
2117
+ }
2118
+ });
2119
+
2033
2120
  // src/commands.ts
2034
- import { join as join10, sep as sep2 } from "path";
2121
+ import { join as join11, sep as sep2 } from "path";
2035
2122
  import { lstat as lstat4, rm as rm3 } from "fs/promises";
2036
2123
  function sessionOptions(options) {
2037
2124
  const password = (options.env ?? process.env).PASEO_PASSWORD;
@@ -2063,7 +2150,7 @@ async function buildDesired(layout, agents, roles) {
2063
2150
  const entries = [
2064
2151
  { kind: "dir", path: layout.roomHome },
2065
2152
  { kind: "dir", path: sharedRoom(layout) },
2066
- { kind: "file", path: join10(sharedRoom(layout), "WORKSPACE_PROTOCOL.md"), content: renderInstructions("workspace") }
2153
+ { kind: "file", path: join11(sharedRoom(layout), "WORKSPACE_PROTOCOL.md"), content: renderInstructions("workspace") }
2067
2154
  ];
2068
2155
  const providers = {};
2069
2156
  const profiles = [];
@@ -2097,17 +2184,19 @@ async function buildDesired(layout, agents, roles) {
2097
2184
  if (agents.every((id) => binaries[id] !== void 0)) {
2098
2185
  entries.push({
2099
2186
  kind: "file",
2100
- path: join10(layout.roomHome, AUTHENTICATION_GUIDE),
2187
+ path: join11(layout.roomHome, AUTHENTICATION_GUIDE),
2101
2188
  content: renderAuthenticationGuide(layout.roomHome, binaries, agents, roles)
2102
2189
  });
2103
2190
  checks.push(warn(
2104
2191
  "room.authentication-not-validated",
2105
- `Role authentication was not validated. Login instructions are managed at ${join10(layout.roomHome, AUTHENTICATION_GUIDE)}.`,
2192
+ `Role authentication was not validated. Login instructions are managed at ${join11(layout.roomHome, AUTHENTICATION_GUIDE)}.`,
2106
2193
  "After setup --apply, follow that guide or run: paseo-room auth login <agent> <role>"
2107
2194
  ));
2108
2195
  }
2109
- entries.push({ kind: "file", path: join10(layout.roomHome, MARKER), content: renderMarker(package_default.version, agents, roles, contractDigest()) });
2110
- return { entries, providers, profiles, checks };
2196
+ const pluginPath = agents.includes("claude") ? claudeCarrierPluginDir(layout) : void 0;
2197
+ if (pluginPath !== void 0) entries.push(...claudeCarrierEntries(layout, roles));
2198
+ entries.push({ kind: "file", path: join11(layout.roomHome, MARKER), content: renderMarker(package_default.version, agents, roles, contractDigest()) });
2199
+ return { entries, providers, profiles, checks, ...pluginPath === void 0 ? {} : { pluginPath } };
2111
2200
  }
2112
2201
  function contractProvenance(previous) {
2113
2202
  const digest = contractDigest();
@@ -2162,10 +2251,10 @@ async function roomPathSafety(layout, agents, roles) {
2162
2251
  const checks = [];
2163
2252
  if (!await safeDirectory(layout.roomHome, checks)) return checks;
2164
2253
  await safeDirectory(sharedRoom(layout), checks);
2165
- const rolesHome = join10(layout.roomHome, "roles");
2254
+ const rolesHome = join11(layout.roomHome, "roles");
2166
2255
  if (!await safeDirectory(rolesHome, checks)) return checks;
2167
2256
  for (const agent of agents) {
2168
- const agentHome = join10(rolesHome, agent);
2257
+ const agentHome = join11(rolesHome, agent);
2169
2258
  if (!await safeDirectory(agentHome, checks)) continue;
2170
2259
  for (const role of roles) await safeDirectory(roleHome(layout, agent, role), checks);
2171
2260
  }
@@ -2186,14 +2275,54 @@ async function staleFrom(layout, previous, agents, roles) {
2186
2275
  if (await exists(path)) retainedDirectories.push(path);
2187
2276
  }
2188
2277
  }
2189
- return { providerIds, profileIds, retainedDirectories };
2278
+ return {
2279
+ providerIds,
2280
+ profileIds,
2281
+ removePlugin: previous.agents.includes("claude") && !agents.includes("claude"),
2282
+ retainedDirectories
2283
+ };
2190
2284
  }
2191
- async function planRoom(session, desired, stale) {
2192
- const live = await session.readProviders();
2193
- const liveProfiles = await session.readProfiles();
2285
+ function pluginPlan(desired, stale, plugins, expectedPath) {
2286
+ const livePlugin = plugins.find((plugin) => plugin.id === CLAUDE_CARRIER_PLUGIN_ID);
2287
+ const shouldManage = desired.pluginPath !== void 0 || stale.removePlugin;
2288
+ if (!shouldManage) return { checks: [] };
2289
+ if (livePlugin !== void 0 && livePlugin.path !== expectedPath) {
2290
+ return {
2291
+ livePlugin,
2292
+ checks: [fail(
2293
+ "claude.plugin.path",
2294
+ `Plugin ${CLAUDE_CARRIER_PLUGIN_ID} is registered from ${livePlugin.path}, not the room-owned path ${expectedPath}.`,
2295
+ "Move or remove that conflicting plugin registration manually, then run setup again."
2296
+ )]
2297
+ };
2298
+ }
2299
+ if (desired.pluginPath !== void 0) {
2300
+ const healthy = livePlugin?.enabled === true && livePlugin.status === "running" && livePlugin.error === void 0;
2301
+ return {
2302
+ ...livePlugin === void 0 ? {} : { livePlugin },
2303
+ operation: { action: livePlugin === void 0 ? "create" : healthy ? "noop" : "update", kind: "plugin", target: CLAUDE_CARRIER_PLUGIN_ID },
2304
+ checks: []
2305
+ };
2306
+ }
2307
+ return {
2308
+ ...livePlugin === void 0 ? {} : { livePlugin },
2309
+ operation: { action: livePlugin === void 0 ? "noop" : "remove", kind: "plugin", target: CLAUDE_CARRIER_PLUGIN_ID },
2310
+ checks: []
2311
+ };
2312
+ }
2313
+ async function planRoom(session, desired, stale, expectedPluginPath) {
2314
+ const inspectPlugins = desired.pluginPath !== void 0 || stale.removePlugin;
2315
+ const [live, liveProfiles, plugins] = await Promise.all([
2316
+ session.readProviders(),
2317
+ session.readProfiles(),
2318
+ inspectPlugins ? session.listPlugins() : Promise.resolve([])
2319
+ ]);
2194
2320
  const byId = new Map(liveProfiles.map((entry) => [String(entry.id), entry]));
2321
+ const plugin = pluginPlan(desired, stale, plugins, expectedPluginPath);
2195
2322
  return {
2196
2323
  liveProfiles,
2324
+ ...plugin.livePlugin === void 0 ? {} : { livePlugin: plugin.livePlugin },
2325
+ pluginChecks: plugin.checks,
2197
2326
  operations: [
2198
2327
  ...await planEntries(desired.entries),
2199
2328
  ...Object.entries(desired.providers).map(([id, provider]) => ({
@@ -2207,10 +2336,53 @@ async function planRoom(session, desired, stale) {
2207
2336
  target: profile.id
2208
2337
  })),
2209
2338
  ...stale.providerIds.filter((id) => id in live).map((id) => ({ action: "remove", kind: "provider", target: id })),
2210
- ...stale.profileIds.filter((id) => byId.has(id)).map((id) => ({ action: "remove", kind: "profile", target: id }))
2339
+ ...stale.profileIds.filter((id) => byId.has(id)).map((id) => ({ action: "remove", kind: "profile", target: id })),
2340
+ ...plugin.operation === void 0 ? [] : [plugin.operation]
2211
2341
  ]
2212
2342
  };
2213
2343
  }
2344
+ function pluginsEnabledCheck(enabled) {
2345
+ return enabled ? pass("claude.plugin.enabled", "Paseo plugins are enabled for the required Claude contract carrier.") : fail(
2346
+ "claude.plugin.enabled",
2347
+ "Claude rooms require the paseo-room trusted server plugin, but Paseo plugins are disabled. Plugins are trusted, unsandboxed code with access to the daemon machine.",
2348
+ "Review that trust boundary, enable plugins in Paseo Settings, reload Paseo, then run setup again."
2349
+ );
2350
+ }
2351
+ function pluginRuntimeCheck(plugin, expectedPath) {
2352
+ if (plugin === void 0) {
2353
+ return fail("claude.plugin.runtime", `Plugin ${CLAUDE_CARRIER_PLUGIN_ID} is not registered.`, "Run: paseo-room setup --apply");
2354
+ }
2355
+ if (plugin.path !== expectedPath) {
2356
+ return fail(
2357
+ "claude.plugin.runtime",
2358
+ `Plugin ${CLAUDE_CARRIER_PLUGIN_ID} is registered from ${plugin.path}, not ${expectedPath}.`,
2359
+ "Move or remove that conflicting plugin registration manually, then run setup again."
2360
+ );
2361
+ }
2362
+ if (!plugin.enabled || plugin.status !== "running" || plugin.error !== void 0) {
2363
+ const detail = plugin.error === void 0 ? `status ${plugin.status}` : `${plugin.status}: ${plugin.error}`;
2364
+ return fail("claude.plugin.runtime", `Plugin ${CLAUDE_CARRIER_PLUGIN_ID} is not running (${detail}).`, "Run: paseo-room setup --apply");
2365
+ }
2366
+ return pass("claude.plugin.runtime", `Claude contract carrier plugin is running from ${expectedPath}.`);
2367
+ }
2368
+ async function reconcilePlugin(session, desired, plan, pending, expectedPath) {
2369
+ if (desired.pluginPath === void 0) {
2370
+ if (plan.livePlugin !== void 0) await session.removePlugin(CLAUDE_CARRIER_PLUGIN_ID);
2371
+ return [];
2372
+ }
2373
+ let plugin = plan.livePlugin;
2374
+ if (plugin === void 0) {
2375
+ plugin = await session.installPlugin(expectedPath, CLAUDE_CARRIER_PLUGIN_ID);
2376
+ } else {
2377
+ if (!plugin.enabled) plugin = await session.enablePlugin(CLAUDE_CARRIER_PLUGIN_ID);
2378
+ const filesChanged = pending.some(
2379
+ (operation) => (operation.kind === "file" || operation.kind === "dir") && operation.target.startsWith(`${expectedPath}${sep2}`)
2380
+ );
2381
+ const needsReload = filesChanged || plugin.status !== "running" || plugin.error !== void 0;
2382
+ if (needsReload) plugin = await session.reloadPlugin(CLAUDE_CARRIER_PLUGIN_ID);
2383
+ }
2384
+ return [pluginRuntimeCheck(plugin, expectedPath)];
2385
+ }
2214
2386
  async function setup(options = {}) {
2215
2387
  const layout = resolveLayout(options, options.env);
2216
2388
  const agents = options.agents ?? ["codex"];
@@ -2222,7 +2394,8 @@ async function setup(options = {}) {
2222
2394
  const safetyAgents = [.../* @__PURE__ */ new Set([...agents, ...previous?.agents ?? []])];
2223
2395
  const pathSafety = await roomPathSafety(layout, safetyAgents, ROLES);
2224
2396
  if (hasFailure(pathSafety)) return failed("setup", pathSafety);
2225
- const daemon = await checkDaemon(layout, options.env, minimumPaseoVersion(agents));
2397
+ const compatibilityAgents = [.../* @__PURE__ */ new Set([...agents, ...previous?.agents ?? []])];
2398
+ const daemon = await checkDaemon(layout, options.env, minimumPaseoVersion(compatibilityAgents));
2226
2399
  if (!daemon.daemon) return failed("setup", daemon.checks);
2227
2400
  const desired = await buildDesired(layout, agents, ROLES);
2228
2401
  const stale = await staleFrom(layout, previous, agents, ROLES);
@@ -2234,19 +2407,24 @@ async function setup(options = {}) {
2234
2407
  const checks = [...daemon.checks, ...desired.checks, ...retained];
2235
2408
  if (hasFailure(checks)) return failed("setup", checks);
2236
2409
  return withSession(daemon.daemon, async (session) => {
2237
- const { operations, liveProfiles } = await planRoom(session, desired, stale);
2410
+ const expectedPluginPath = claudeCarrierPluginDir(layout);
2411
+ const plan = await planRoom(session, desired, stale, expectedPluginPath);
2412
+ const { operations, liveProfiles } = plan;
2238
2413
  const pending = operations.filter((operation) => operation.action !== "noop");
2239
- const preApplyDiagnostics = [...checks, ...contractProvenance(previous), ...thinkingDiagnostics(liveProfiles, desired.profiles)];
2240
- if (!options.apply) {
2414
+ const pluginPreconditions = desired.pluginPath === void 0 ? plan.pluginChecks : [pluginsEnabledCheck(await session.pluginsEnabled()), ...plan.pluginChecks];
2415
+ const preApplyDiagnostics = [...checks, ...pluginPreconditions, ...contractProvenance(previous), ...thinkingDiagnostics(liveProfiles, desired.profiles)];
2416
+ if (hasFailure(preApplyDiagnostics) || !options.apply) {
2241
2417
  return {
2242
2418
  command: "setup",
2243
- outcome: pending.length > 0 ? "changes-planned" : "ok",
2419
+ outcome: hasFailure(preApplyDiagnostics) ? "failed" : pending.length > 0 ? "changes-planned" : "ok",
2244
2420
  changed: false,
2245
2421
  checks: preApplyDiagnostics,
2246
2422
  operations
2247
2423
  };
2248
2424
  }
2249
- await applyEntries(desired.entries);
2425
+ const markerPath = join11(layout.roomHome, MARKER);
2426
+ const markerEntries = desired.entries.filter((entry) => entry.path === markerPath);
2427
+ await applyEntries(desired.entries.filter((entry) => entry.path !== markerPath));
2250
2428
  const ids = Object.keys(desired.providers);
2251
2429
  await session.writeProviders(desired.providers);
2252
2430
  if (stale.providerIds.length > 0) await session.removeProviders(stale.providerIds);
@@ -2256,24 +2434,27 @@ async function setup(options = {}) {
2256
2434
  await session.writeProfiles(appliedProfiles);
2257
2435
  }
2258
2436
  await session.refresh(ids);
2437
+ const pluginChecks = await reconcilePlugin(session, desired, plan, pending, expectedPluginPath);
2438
+ await applyEntries(markerEntries);
2259
2439
  const appliedMarker = {
2260
2440
  version: package_default.version,
2261
2441
  agents: [...agents],
2262
2442
  roles: [...ROLES],
2263
2443
  contract: contractDigest()
2264
2444
  };
2445
+ const appliedChecks = [
2446
+ ...checks,
2447
+ ...pluginPreconditions,
2448
+ ...pluginChecks,
2449
+ pass("room.applied", `Room ready at ${layout.roomHome} with ${String(ids.length)} Paseo providers.`),
2450
+ ...contractProvenance(appliedMarker),
2451
+ ...thinkingDiagnostics(appliedProfiles, desired.profiles)
2452
+ ];
2265
2453
  return {
2266
2454
  command: "setup",
2267
- outcome: "ok",
2455
+ outcome: hasFailure(appliedChecks) ? "failed" : "ok",
2268
2456
  changed: pending.length > 0,
2269
- // Report success before non-blocking diagnostics so it cannot read as if a later pass
2270
- // cleared a warning. `appliedProfiles` is the exact array written above when one changed.
2271
- checks: [
2272
- ...checks,
2273
- pass("room.applied", `Room ready at ${layout.roomHome} with ${String(ids.length)} Paseo providers.`),
2274
- ...contractProvenance(appliedMarker),
2275
- ...thinkingDiagnostics(appliedProfiles, desired.profiles)
2276
- ],
2457
+ checks: appliedChecks,
2277
2458
  operations
2278
2459
  };
2279
2460
  }, sessionOptions(options));
@@ -2296,18 +2477,26 @@ async function verify(options = {}) {
2296
2477
  const checks = [...daemon.checks, ...desired.checks];
2297
2478
  if (hasFailure(checks)) return failed("verify", checks);
2298
2479
  return withSession(daemon.daemon, async (session) => {
2299
- const { operations, liveProfiles } = await planRoom(session, desired, NOTHING_STALE);
2480
+ const expectedPluginPath = claudeCarrierPluginDir(layout);
2481
+ const plan = await planRoom(session, desired, NOTHING_STALE, expectedPluginPath);
2482
+ const { operations, liveProfiles } = plan;
2300
2483
  const drifted = operations.filter((operation) => operation.action !== "noop");
2301
2484
  const count = (kind) => drifted.filter((operation) => operation.kind === kind).length;
2302
2485
  const providers = count("provider");
2303
2486
  const profiles = count("profile");
2304
- const driftedFiles = drifted.filter((operation) => operation.kind !== "provider" && operation.kind !== "profile");
2487
+ const driftedFiles = drifted.filter((operation) => !["provider", "profile", "plugin"].includes(operation.kind));
2305
2488
  const files = driftedFiles.length;
2489
+ const pluginChecks = desired.pluginPath === void 0 ? [] : [
2490
+ pluginsEnabledCheck(await session.pluginsEnabled()),
2491
+ ...plan.pluginChecks,
2492
+ pluginRuntimeCheck(plan.livePlugin, expectedPluginPath)
2493
+ ];
2306
2494
  const all = [
2307
2495
  ...checks,
2308
2496
  files === 0 ? pass("room.files", "Every managed role file matches the current definition.") : fail("room.files", `${String(files)} managed role files are missing or outdated.${fileDriftDetail(driftedFiles.map((operation) => operation.target))}`, "Run: paseo-room setup --apply"),
2309
2497
  providers === 0 ? pass("room.providers", `All ${String(Object.keys(desired.providers).length)} Paseo providers are registered as expected.`) : fail("room.providers", `${String(providers)} Paseo providers are missing or differ.`, "Run: paseo-room setup --apply"),
2310
2498
  profiles === 0 ? pass("room.profiles", `All ${String(desired.profiles.length)} Paseo agent profiles are registered as expected.`) : fail("room.profiles", `${String(profiles)} Paseo agent profiles are missing or differ.`, "Run: paseo-room setup --apply"),
2499
+ ...pluginChecks,
2311
2500
  // Diagnostics last: a warning never precedes the failure it might be mistaken for.
2312
2501
  ...contractProvenance(marker),
2313
2502
  ...thinkingDiagnostics(liveProfiles, desired.profiles)
@@ -2333,9 +2522,11 @@ async function remove(options = {}) {
2333
2522
  if (invalid.length > 0) return failed("remove", invalid);
2334
2523
  const ids = marker.agents.flatMap((agent) => marker.roles.map((role) => providerId(agent, role)));
2335
2524
  const profiles = new Set(marker.agents.flatMap((agent) => marker.roles.map((role) => profileId(agent, role))));
2525
+ const hasClaude = marker.agents.includes("claude");
2336
2526
  const operations = [
2337
2527
  ...ids.map((id) => ({ action: "remove", kind: "provider", target: id })),
2338
2528
  ...[...profiles].map((id) => ({ action: "remove", kind: "profile", target: id })),
2529
+ ...hasClaude ? [{ action: "remove", kind: "plugin", target: CLAUDE_CARRIER_PLUGIN_ID }] : [],
2339
2530
  { action: "remove", kind: "dir", target: layout.roomHome }
2340
2531
  ];
2341
2532
  const credentialWarning = warn(
@@ -2360,6 +2551,14 @@ async function remove(options = {}) {
2360
2551
  }
2361
2552
  try {
2362
2553
  await withSession(daemon.daemon, async (session) => {
2554
+ if (hasClaude) {
2555
+ const plugin = (await session.listPlugins()).find((entry) => entry.id === CLAUDE_CARRIER_PLUGIN_ID);
2556
+ const expectedPath = claudeCarrierPluginDir(layout);
2557
+ if (plugin !== void 0 && plugin.path !== expectedPath) {
2558
+ throw new Error(`refusing to remove foreign plugin path ${plugin.path}`);
2559
+ }
2560
+ if (plugin !== void 0) await session.removePlugin(CLAUDE_CARRIER_PLUGIN_ID);
2561
+ }
2363
2562
  await session.removeProviders(ids);
2364
2563
  const live = await session.readProfiles();
2365
2564
  const kept = live.filter((entry) => !profiles.has(String(entry.id)));
@@ -2403,12 +2602,13 @@ var init_commands = __esm({
2403
2602
  init_fsops();
2404
2603
  init_layout();
2405
2604
  init_paseo();
2605
+ init_plugin();
2406
2606
  init_result();
2407
2607
  init_instructions();
2408
2608
  init_room();
2409
2609
  init_roles();
2410
2610
  AGENTS = { codex: codexAgent, claude: claudeAgent, pi: piAgent };
2411
- NOTHING_STALE = { providerIds: [], profileIds: [], retainedDirectories: [] };
2611
+ NOTHING_STALE = { providerIds: [], profileIds: [], removePlugin: false, retainedDirectories: [] };
2412
2612
  }
2413
2613
  });
2414
2614