Skip to content

feat: seamless adapter switching with permission defaults#1935

Open
apsisvictor-sys wants to merge 4 commits intopaperclipai:masterfrom
apsisvictor-sys:feat/seamless-adapter-switching
Open

feat: seamless adapter switching with permission defaults#1935
apsisvictor-sys wants to merge 4 commits intopaperclipai:masterfrom
apsisvictor-sys:feat/seamless-adapter-switching

Conversation

@apsisvictor-sys
Copy link
Copy Markdown

Summary

Switching agents between claude_local, codex_local, and opencode_local adapters currently requires manually reconfiguring permission bypass flags, model settings, and other adapter-specific fields every time. This PR makes adapter switching a one-click operation from the dashboard by:

  1. Auto-applying permission bypass defaults per adapter type — When an agent is switched to a new adapter, the server automatically applies the correct autonomous-mode configuration:

    • claude_localdangerouslySkipPermissions: true
    • codex_localdangerouslyBypassApprovalsAndSandbox: true (already existed)
    • opencode_local → injects OPENCODE_PERMISSION env var with all permissions set to "allow" (OpenCode has no CLI flag equivalent)
  2. Preserving cross-adapter config on switch — Expands CROSS_ADAPTER_CONFIG_KEYS to also preserve promptTemplate, bootstrapPromptTemplate, extraArgs, timeoutSec, and graceSec across adapter switches. Previously these were silently dropped.

  3. UI-side defaults on adapter change — The AgentConfigForm now applies permission bypass defaults when switching adapters in edit mode, matching what the server does so the form state is immediately correct.

Additional fixes in this PR

  • Heartbeat queue recursionstartNextQueuedRunForAgent is now called via setImmediate to break the call stack when many runs are queued for the same agent, preventing potential stack overflow.
  • Heartbeat error visibilityfinalizeAgentStatus errors in the outer catch are now logged instead of silently swallowed, making it easier to diagnose agents stuck in "running" state.
  • Claude adapter skills cleanupfs.rm for the ephemeral skills directory is now awaited to prevent orphan temp directories.
  • Claude adapter session resume — Decomposed session resume condition for clarity and added a log message when resuming without cwd validation.
  • Codex idle heartbeat optimization — Idle heartbeat runs now always start fresh sessions to prevent unbounded session growth and cache-miss costs (~400K–900K fresh tokens per idle run).
  • gemini_local adapter type — Added to AGENT_ADAPTER_TYPES enum (the adapter package existed but the type was missing from the constant).
  • gitignore — Added .paperclip-home/ to prevent accidental commit of instance databases, agent configs, and company data.

How it works

When PATCH /agents/:id detects an adapter type change:

1. preserveCrossAdapterConfig() keeps shared fields (cwd, env, instructions, skills, prompts, timeouts)
2. Adapter-specific fields from the old adapter are dropped (model, chrome, search, etc.)
3. applyCreateDefaultsByAdapterType() applies the new adapter's defaults (permissions, model defaults)
4. User's explicit overrides in the PATCH body take precedence over all defaults

The OPENCODE_PERMISSION env var injection respects existing values — if a user has already set a custom permission policy, it won't be overwritten.

Test plan

  • New test: adapter switch preserves promptTemplate, extraArgs, timeoutSec, graceSec
  • New test: switching to claude_local auto-applies dangerouslySkipPermissions: true
  • New test: switching to opencode_local injects OPENCODE_PERMISSION env var
  • New test: existing OPENCODE_PERMISSION env var is not overridden
  • Existing test: cross-adapter config preservation (cwd, instructions, skills) still passes
  • Full test suite: 574 passed, 0 failed
  • TypeScript: server + UI both pass tsc --noEmit

🤖 Generated with Claude Code

apsisvictor-sys and others added 3 commits March 27, 2026 21:57
Prevents accidentally committing agent databases, instruction files,
and company configuration to the repository.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
When switching agents between claude_local, codex_local, and
opencode_local adapters:

- claude_local: auto-applies dangerouslySkipPermissions: true
- opencode_local: injects OPENCODE_PERMISSION env var (all-allow)
- Expands CROSS_ADAPTER_CONFIG_KEYS to preserve promptTemplate,
  bootstrapPromptTemplate, extraArgs, timeoutSec, graceSec across
  adapter switches
- UI edit mode applies permission bypass defaults on adapter change

Enables one-click adapter switching from the dashboard without
manual permission or config reconfiguration.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- claude-local: session cwd validation warning before resume
- codex-local: idle heartbeat session optimization
- heartbeat: log finalizeAgentStatus errors instead of swallowing;
  break queue recursion with setImmediate
- shared/constants: add gemini_local to AGENT_ADAPTER_TYPES

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 27, 2026

Greptile Summary

This PR makes adapter switching a one-click operation by auto-applying permission defaults per adapter type, preserving shared config fields across switches, and keeping the UI form state in sync with server behaviour. It also bundles several orthogonal reliability fixes (heartbeat queue recursion, silent error swallowing, orphan temp dirs, idle session growth).

Key changes:

  • preserveCrossAdapterConfig + expanded CROSS_ADAPTER_CONFIG_KEYS preserve promptTemplate, extraArgs, timeoutSec, graceSec, etc. across adapter switches on both server and UI.
  • applyCreateDefaultsByAdapterType now sets dangerouslySkipPermissions: true for claude_local and injects OPENCODE_PERMISSION env var for opencode_local on switch.
  • PATCH /agents/:id adapterConfig handling changed from replace to merge semantics — existing fields not in the patch body are now preserved. This is more correct for a PATCH endpoint but is a silent breaking change for API consumers that relied on the old replace behaviour.
  • When a client patches only adapterType (without adapterConfig), preserveCrossAdapterConfig is never called, leaving old adapter-specific fields in the config on the new adapter.
  • openclaw_gateway was quietly added to ENABLED_ADAPTER_TYPES in the UI without being mentioned in the PR description.
  • setImmediate wrapping for startNextQueuedRunForAgent and outer-catch error logging in heartbeat.ts are clean, targeted fixes.
  • Codex idle runs now always start fresh sessions to prevent unbounded session growth.
  • The PR description is missing a thinking path as required by CONTRIBUTING.md. Please add one to help reviewers and future contributors understand the chain of reasoning that led to these changes.

Confidence Score: 4/5

Safe to merge after addressing the API semantics change documentation and the missing thinking path; all remaining findings are P2 or lower.

All 574 tests pass and TypeScript compiles cleanly. The core adapter-switch logic is well-tested. The main concerns are P2: a silent replace→merge semantics change to the PATCH adapterConfig field (breaking for direct API consumers, correct for a PATCH endpoint), a gap where adapter-only switches without adapterConfig don't strip old fields, an undocumented openclaw_gateway enablement, and a missing PR thinking path per CONTRIBUTING.md.

server/src/routes/agents.ts — review the replace→merge semantics change and the adapter-type-only patch gap; ui/src/components/AgentConfigForm.tsx — confirm the openclaw_gateway addition is intentional.

Important Files Changed

Filename Overview
server/src/routes/agents.ts Core adapter-switch logic added: preserveCrossAdapterConfig, applyCreateDefaultsByAdapterType extended, and adapterConfig now merged (not replaced) on PATCH — a silent but potentially breaking API semantics change; also adds runtimeConfig merge; adapter-type-only PATCH (no adapterConfig) doesn't strip old adapter fields.
server/src/tests/agent-update-routes.test.ts New test file covering adapter-switch config preservation, dangerouslySkipPermissions default, OPENCODE_PERMISSION injection, and the no-override guard — good test coverage for the new behaviour.
server/src/services/heartbeat.ts Two targeted fixes: setImmediate breaks recursive startNextQueuedRunForAgent call stack, and outer-catch finalizeAgentStatus errors are now logged instead of silently swallowed — both are clean correctness improvements.
ui/src/components/AgentConfigForm.tsx UI applies cross-adapter field preservation and dangerouslySkipPermissions default on adapter switch; openclaw_gateway added to enabled adapters without mention; OPENCODE_PERMISSION env var not injected in the overlay (server handles it on save, but form is visually incomplete until refresh).
packages/adapters/claude-local/src/server/execute.ts Two clean fixes: fs.rm for the ephemeral skills dir is now properly awaited, and the session-resume condition is decomposed with a new log message for the cwdUnknown case — no issues.
packages/adapters/codex-local/src/server/execute.ts Idle heartbeat runs are now forced to start fresh sessions (isTaskRun guard) to prevent unbounded session growth — well-justified and clearly documented in comments.
packages/shared/src/constants.ts Adds missing gemini_local to AGENT_ADAPTER_TYPES enum — simple, correct addition.
.gitignore Adds .paperclip-home/ and .gstack/ to prevent accidental commits of instance data — straightforward and appropriate.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: server/src/routes/agents.ts
Line: 1758-1766

Comment:
**`adapterConfig` PATCH semantics silently changed from replace to merge**

Previously, sending `PATCH /agents/:id { "adapterConfig": { "model": "x" } }` would **replace** the entire stored `adapterConfig` with the provided object. This PR changes that to a **merge** — existing fields that aren't in the patch body are now preserved automatically.

This is arguably more correct for a PATCH endpoint (RFC 7386), but it is a **breaking change** for any existing API consumer or script that relied on the old replace-semantics to clear stale fields. For example, a caller that used to send a minimal `adapterConfig` to explicitly reset the config to a known state will now silently accumulate all previous fields.

Consider documenting this behaviour change in `docs/api/agents.md` and the PR description so downstream consumers are aware.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: server/src/routes/agents.ts
Line: 1745-1766

Comment:
**Adapter-specific fields not stripped when only `adapterType` is patched**

When a client sends `PATCH /agents/:id { "adapterType": "claude_local" }` **without** an `adapterConfig` key, the `if (hasOwnProperty(patchData, "adapterConfig"))` block is skipped entirely. `preserveCrossAdapterConfig` never runs, and `applyCreateDefaultsByAdapterType` receives the **full existing adapter config** — including all adapter-specific fields from the old adapter (e.g. `chrome`, `command`, `url`).

This leaves stale, adapter-incompatible fields in the stored config whenever a caller switches adapter type via the API without supplying a new `adapterConfig`.

Consider adding a guard for the adapter-type-change-only path, e.g.:

```ts
if (adapterTypeChanged && !Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")) {
  const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {};
  patchData.adapterConfig = preserveCrossAdapterConfig(existingAdapterConfig);
}
```

The UI always sends `adapterConfig`, so this doesn't affect the current frontend, but it makes the API contract fragile for direct callers.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: ui/src/components/AgentConfigForm.tsx
Line: 599-606

Comment:
**`OPENCODE_PERMISSION` env var not injected in the UI overlay on adapter switch**

The server's `applyCreateDefaultsByAdapterType` injects `OPENCODE_PERMISSION` into the agent's `env` map when switching to `opencode_local`. The UI overlay for the edit-mode adapter switch (lines 574–607) correctly sets `dangerouslySkipPermissions` for `claude_local` and `dangerouslyBypassApprovalsAndSandbox` for `codex_local`, but has no equivalent injection for `opencode_local`.

As a result, the env section of the form won't show `OPENCODE_PERMISSION` immediately after the user switches to `opencode_local` — it only appears after the form is saved and re-fetched from the server. This is a visual inconsistency rather than a correctness bug (the server applies the default on save), but it does mean the form shows an incomplete state while the user is still configuring the agent.

Consider adding the same injection in the UI overlay:
```ts
...(t === "claude_local"
  ? { dangerouslySkipPermissions: true }
  : t === "codex_local"
    ? { dangerouslyBypassApprovalsAndSandbox: DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX }
    : t === "opencode_local"
      ? { env: { ...preserved.env as object, OPENCODE_PERMISSION: OPENCODE_PERMISSION_ALLOW_ALL } }
      : {}),
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: ui/src/components/AgentConfigForm.tsx
Line: 992

Comment:
**`openclaw_gateway` added to `ENABLED_ADAPTER_TYPES` without mention in PR description**

`openclaw_gateway` was quietly added to the set of enabled adapter types in the UI. This means users will now see it as a selectable option in the adapter dropdown. Is this intentional as part of this PR? The PR description doesn't mention it, and it may warrant its own entry in the "additional fixes" section along with a brief note on what it enables.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix: adapter session validation, heartbe..." | Re-trigger Greptile

Comment on lines +1758 to +1766
patchData.adapterConfig = adapterTypeChanged
? {
...preserveCrossAdapterConfig(existingAdapterConfig),
...adapterConfig,
}
: {
...existingAdapterConfig,
...adapterConfig,
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 adapterConfig PATCH semantics silently changed from replace to merge

Previously, sending PATCH /agents/:id { "adapterConfig": { "model": "x" } } would replace the entire stored adapterConfig with the provided object. This PR changes that to a merge — existing fields that aren't in the patch body are now preserved automatically.

This is arguably more correct for a PATCH endpoint (RFC 7386), but it is a breaking change for any existing API consumer or script that relied on the old replace-semantics to clear stale fields. For example, a caller that used to send a minimal adapterConfig to explicitly reset the config to a known state will now silently accumulate all previous fields.

Consider documenting this behaviour change in docs/api/agents.md and the PR description so downstream consumers are aware.

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/routes/agents.ts
Line: 1758-1766

Comment:
**`adapterConfig` PATCH semantics silently changed from replace to merge**

Previously, sending `PATCH /agents/:id { "adapterConfig": { "model": "x" } }` would **replace** the entire stored `adapterConfig` with the provided object. This PR changes that to a **merge** — existing fields that aren't in the patch body are now preserved automatically.

This is arguably more correct for a PATCH endpoint (RFC 7386), but it is a **breaking change** for any existing API consumer or script that relied on the old replace-semantics to clear stale fields. For example, a caller that used to send a minimal `adapterConfig` to explicitly reset the config to a known state will now silently accumulate all previous fields.

Consider documenting this behaviour change in `docs/api/agents.md` and the PR description so downstream consumers are aware.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 1745 to +1766
@@ -1701,11 +1754,30 @@ export function agentRoutes(db: Db) {
if (changingInstructionsPath) {
await assertCanManageInstructionsPath(req, existing);
}
patchData.adapterConfig = adapterConfig;
const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {};
patchData.adapterConfig = adapterTypeChanged
? {
...preserveCrossAdapterConfig(existingAdapterConfig),
...adapterConfig,
}
: {
...existingAdapterConfig,
...adapterConfig,
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Adapter-specific fields not stripped when only adapterType is patched

When a client sends PATCH /agents/:id { "adapterType": "claude_local" } without an adapterConfig key, the if (hasOwnProperty(patchData, "adapterConfig")) block is skipped entirely. preserveCrossAdapterConfig never runs, and applyCreateDefaultsByAdapterType receives the full existing adapter config — including all adapter-specific fields from the old adapter (e.g. chrome, command, url).

This leaves stale, adapter-incompatible fields in the stored config whenever a caller switches adapter type via the API without supplying a new adapterConfig.

Consider adding a guard for the adapter-type-change-only path, e.g.:

if (adapterTypeChanged && !Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")) {
  const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {};
  patchData.adapterConfig = preserveCrossAdapterConfig(existingAdapterConfig);
}

The UI always sends adapterConfig, so this doesn't affect the current frontend, but it makes the API contract fragile for direct callers.

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/routes/agents.ts
Line: 1745-1766

Comment:
**Adapter-specific fields not stripped when only `adapterType` is patched**

When a client sends `PATCH /agents/:id { "adapterType": "claude_local" }` **without** an `adapterConfig` key, the `if (hasOwnProperty(patchData, "adapterConfig"))` block is skipped entirely. `preserveCrossAdapterConfig` never runs, and `applyCreateDefaultsByAdapterType` receives the **full existing adapter config** — including all adapter-specific fields from the old adapter (e.g. `chrome`, `command`, `url`).

This leaves stale, adapter-incompatible fields in the stored config whenever a caller switches adapter type via the API without supplying a new `adapterConfig`.

Consider adding a guard for the adapter-type-change-only path, e.g.:

```ts
if (adapterTypeChanged && !Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")) {
  const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {};
  patchData.adapterConfig = preserveCrossAdapterConfig(existingAdapterConfig);
}
```

The UI always sends `adapterConfig`, so this doesn't affect the current frontend, but it makes the API contract fragile for direct callers.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +599 to +606
...(t === "claude_local"
? { dangerouslySkipPermissions: true }
: t === "codex_local"
? {
dangerouslyBypassApprovalsAndSandbox:
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
}
: {}),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 OPENCODE_PERMISSION env var not injected in the UI overlay on adapter switch

The server's applyCreateDefaultsByAdapterType injects OPENCODE_PERMISSION into the agent's env map when switching to opencode_local. The UI overlay for the edit-mode adapter switch (lines 574–607) correctly sets dangerouslySkipPermissions for claude_local and dangerouslyBypassApprovalsAndSandbox for codex_local, but has no equivalent injection for opencode_local.

As a result, the env section of the form won't show OPENCODE_PERMISSION immediately after the user switches to opencode_local — it only appears after the form is saved and re-fetched from the server. This is a visual inconsistency rather than a correctness bug (the server applies the default on save), but it does mean the form shows an incomplete state while the user is still configuring the agent.

Consider adding the same injection in the UI overlay:

...(t === "claude_local"
  ? { dangerouslySkipPermissions: true }
  : t === "codex_local"
    ? { dangerouslyBypassApprovalsAndSandbox: DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX }
    : t === "opencode_local"
      ? { env: { ...preserved.env as object, OPENCODE_PERMISSION: OPENCODE_PERMISSION_ALLOW_ALL } }
      : {}),
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/components/AgentConfigForm.tsx
Line: 599-606

Comment:
**`OPENCODE_PERMISSION` env var not injected in the UI overlay on adapter switch**

The server's `applyCreateDefaultsByAdapterType` injects `OPENCODE_PERMISSION` into the agent's `env` map when switching to `opencode_local`. The UI overlay for the edit-mode adapter switch (lines 574–607) correctly sets `dangerouslySkipPermissions` for `claude_local` and `dangerouslyBypassApprovalsAndSandbox` for `codex_local`, but has no equivalent injection for `opencode_local`.

As a result, the env section of the form won't show `OPENCODE_PERMISSION` immediately after the user switches to `opencode_local` — it only appears after the form is saved and re-fetched from the server. This is a visual inconsistency rather than a correctness bug (the server applies the default on save), but it does mean the form shows an incomplete state while the user is still configuring the agent.

Consider adding the same injection in the UI overlay:
```ts
...(t === "claude_local"
  ? { dangerouslySkipPermissions: true }
  : t === "codex_local"
    ? { dangerouslyBypassApprovalsAndSandbox: DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX }
    : t === "opencode_local"
      ? { env: { ...preserved.env as object, OPENCODE_PERMISSION: OPENCODE_PERMISSION_ALLOW_ALL } }
      : {}),
```

How can I resolve this? If you propose a fix, please make it concise.

</div>
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 openclaw_gateway added to ENABLED_ADAPTER_TYPES without mention in PR description

openclaw_gateway was quietly added to the set of enabled adapter types in the UI. This means users will now see it as a selectable option in the adapter dropdown. Is this intentional as part of this PR? The PR description doesn't mention it, and it may warrant its own entry in the "additional fixes" section along with a brief note on what it enables.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/components/AgentConfigForm.tsx
Line: 992

Comment:
**`openclaw_gateway` added to `ENABLED_ADAPTER_TYPES` without mention in PR description**

`openclaw_gateway` was quietly added to the set of enabled adapter types in the UI. This means users will now see it as a selectable option in the adapter dropdown. Is this intentional as part of this PR? The PR description doesn't mention it, and it may warrant its own entry in the "additional fixes" section along with a brief note on what it enables.

How can I resolve this? If you propose a fix, please make it concise.

…, revert openclaw_gateway

- Handle edge case where PATCH sends only adapterType without adapterConfig:
  now strips adapter-specific fields via preserveCrossAdapterConfig
- Remove openclaw_gateway from ENABLED_ADAPTER_TYPES (unintentional inclusion)
- Add test for adapter-type-only switch path

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant