mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
feat: default Claude to the native runner on Apple Silicon
Carry Claude effort through versioned native admission and recovery. Preserve existing connections and saved runner choices, and price Opus 5.5 API receipts. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
058af8f1b6
commit
6fd56c445d
77 files changed
+909
-296
No files matched your search
+17
-3
@@ -1435,14 +1435,18 @@ CLI paths that use stdin avoid that separate limit for the wake prompt.
|
||||
|
||||
## Paperclip Runner Adapter Conversion
|
||||
|
||||
The experimental Paperclip Runner offers native Codex, OpenCode, and **ACPX
|
||||
Claude**. Converting an existing Claude, Codex, or OpenCode agent selects its
|
||||
Paperclip Runner offers native Codex, OpenCode, and **ACPX Claude**. New
|
||||
Claude Code agents default to ACPX Claude on Apple Silicon; other Claude targets
|
||||
retain Legacy until platform qualification is complete. The runner override is
|
||||
in Advanced. Ordinary Claude models default to `claude-opus-5-5`; explicit models
|
||||
and provider routing remain authoritative. Existing agents keep their saved runner. Converting an existing Claude, Codex, or OpenCode agent selects its
|
||||
corresponding provider, preserves compatible models, credentials, workspace,
|
||||
and instructions, and resets execution sessions while retaining run history.
|
||||
Other adapters require an explicit provider choice. Legacy ACPX Codex agent
|
||||
settings normalize to native Codex on configuration updates and before fresh
|
||||
runs; immutable run descriptors remain readable. The **Paperclip Runner**
|
||||
experimental setting and company access checks still apply.
|
||||
experimental setting applies to other native providers. Native Codex and ACPX
|
||||
Claude do not require it. Company access checks still apply.
|
||||
|
||||
Agent configuration uses the same section layout across adapters: model and
|
||||
provider belong to **Adapter**, environment variables have their own section,
|
||||
@@ -1470,6 +1474,16 @@ retries stopped. Reinstall or rebuild Paperclip with its provider dependencies,
|
||||
restart the server, and run the agent's runtime test before retrying the task.
|
||||
Changing the model or signing in again cannot repair a missing runtime package.
|
||||
|
||||
ACPX Claude uses the existing API-key and subscription connection flows,
|
||||
including built-in login and credential checks. Connection setup and a successful
|
||||
native task are separate checks. Thinking effort (`low`, `medium`, `high`,
|
||||
`xhigh`, `max`, where supported by the selected model) travels in native input v7.
|
||||
The runner requires the provider to acknowledge it before prompting and on resume.
|
||||
An ignored setting or unavailable model fails admission; no runner fallback occurs.
|
||||
Chrome integration, custom commands/state paths, warm-process controls,
|
||||
confinement settings and turn limits require an explicit Legacy choice. Native
|
||||
forms omit Legacy-only defaults. Native permissions keep their saved policy.
|
||||
|
||||
ACPX Claude supports Linux x64 and macOS ARM64/x64 with pinned SDK executables.
|
||||
On macOS the launcher uses private verified module/executable snapshots instead
|
||||
of Linux `/proc` descriptors. Dependency isolation, process ownership, and
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Claude runner defaults
|
||||
|
||||
Updated 2026-10-12.
|
||||
|
||||
## Outcome and constraints
|
||||
|
||||
Start from `058af8f1b69c5fa3d9bc974b9eab73c141b52823`; branch `codex/claude-runner-default`, checkout `/Users/dotta/.codex/worktrees/670b/paperclip`. New Claude agents eventually default to native ACPX Claude on Apple Silicon, Intel Mac, Linux x64 and Linux ARM64. Codex ARM64 defaults remain unchanged. Ordinary Claude defaults become Opus 5.5; explicit models and existing runner selections remain intact.
|
||||
|
||||
Local Apple Silicon API-key task and follow-up are the first gate. Keep existing connection/login tools. Complete core settings and local subscription next, then package/qualify other targets. Two reviewable PRs; no merge or deployment. Linux ARM64 covers installed hosts and SSH, not ARM cloud images. Separate $50 Claude ceiling including setup probes, unknown-charge holds and task turns. No automatic paid retry loops. Docker and expensive Rust builds run in cloud infrastructure; never build Docker images on this Mac.
|
||||
|
||||
## Current state
|
||||
|
||||
- Reviewed source chat “Make Paperclip Runner the default”; source worktree remains separate and active.
|
||||
- Isolated feature branch starts at the exact requested SHA.
|
||||
- Workspace dependencies installed with pinned pnpm 9.15.4 and lifecycle scripts disabled.
|
||||
- Local key source exists; no credentials retained here.
|
||||
- Verified baseline Apple Silicon daemon is available at `/private/tmp/pr2-d522-mac-remote-daemon/darwin-arm64/paperclip-runnerd`; use its receipt and digest for phase 1.
|
||||
- Local default selection, narrow experimental-gate graduation, Opus defaults, and shared runner UI are being implemented. Claude effort transport and acknowledgment are being implemented; the live milestone used default effort on the unchanged baseline daemon.
|
||||
|
||||
## Remaining gates
|
||||
|
||||
1. Local API-key onboarding/task/follow-up passed; retain evidence while runtime components change.
|
||||
2. Core settings propagation, all preservation paths, subscription acceptance.
|
||||
3. Four-target package and remote proof; enable additional defaults only after qualification.
|
||||
4. Required checks, final-source review, PRs, inspectable evidence and working preview.
|
||||
|
||||
## Budget
|
||||
|
||||
Ceiling: $50. Two successful API-key task turns and one successful built-in setup probe. Retain $8 for the initial unpriced receipt/setup uncertainty, plus an $8 additional billed-spend allowance with hard stops and no automatic paid retries; total conservative hold/allowance $16. The follow-up estimate consumes $0.269886 of that additional allowance. These are qualification estimates/reservations, not provider invoices.
|
||||
|
||||
## Local API checkpoint
|
||||
|
||||
Source base plus uncommitted feature changes; preview `http://127.0.0.1:3105`, isolated home `/private/tmp/claude-runner-default-qa`. Initial CLI seed and ordinary UI creation both saved native Claude / Opus 5.5. Native experiment was disabled before UI setup and task. Existing setup tools succeeded using the verified SDK CLI 2.1.286 in this instance-only PATH; global CLI 2.1.245 was insufficient for Opus 5.5 and was not modified. PostgreSQL install required local postinstall links/signature repair after lifecycle-disabled installation.
|
||||
|
||||
UI agent `a0e75080-86de-427a-8ac3-3743c6d076ae`; task `CLA-1`, `6ddff45d-9974-4bd6-976a-f586393ed8f5`. First native run `42773dcb-cbb2-4deb-a495-ca5b3f451780` succeeded with `acpx_runtime` and observed model `claude-opus-5-5`. Independent attachment download: 125 bytes, sha256 `839680679537dfcc8700661347623f0fc1eef7a35e4593dd851a1389bbdc14fc`, three requested checklist items.
|
||||
|
||||
The run exposed missing Opus 5.5 pricing. Its immutable receipt remains unpriced, with reported input 15611, cache-write 15599, cached-input 135871 and output 2194 tokens. Preserve an $8 hold. A narrow rate-card addition uses official standard rates 4 / 0.2 / 8 (one-hour cache-write upper bound) / 20 USD per million; first-run independent upper-rate estimate is $0.1958942, not an invoice. Both QA policies blocked unpriced usage and paused after completion. Before the follow-up, retain the $8 hold as explicit policy reservation, allow the known historical unpriced receipt only in these disposable QA policies, keep the $8 additional billed-spend hard stop and no automatic paid retries. Total conservative hold/allowance $16 of the $50 ceiling. No historical receipt is rewritten.
|
||||
|
||||
Checks: 66 selection/model tests, 111 agent form tests, 78 other affected UI tests, UI/server noemit and token gates passed. Native eligibility regression passes; Opus pricing check passes after the provenance formatting correction. The mocked route suite subsequently passed 47 tests with localhost access enabled; the initial timeouts came from sandbox listener restrictions.
|
||||
|
||||
|
||||
Follow-up run `60e7f9a2-aff3-40b3-8493-2ede6c59da72` succeeded after a controller restart. The task session was reused and the observed model remained Opus 5.5. Estimated Anthropic API usage: `$0.269886000`. Second attachment is 178 bytes, sha256 `ae6fad75dfa46c4c444ad203dff8029359721e19249c04ec1faba9eb6c3d3341`, preserving the first three items and adding the requested revision. Independently re-downloaded the original after the follow-up and confirmed it remained unchanged. Both runs reached Done; saved agent configuration and native experiment=false persisted across restart.
|
||||
|
||||
Product evidence: [task and both downloadable artifacts](http://127.0.0.1:3105/CLA/issues/CLA-1), [first artifact](http://127.0.0.1:3105/api/attachments/3b1b6033-78b4-4a0b-99ae-de363d7f5071/content?download=1), [revision](http://127.0.0.1:3105/api/attachments/857add4a-ef8c-4146-b9c5-be9affc32565/content?download=1). Sanitized receipt: `/private/tmp/claude-local-api-acceptance.json`.
|
||||
|
||||
Runtime: daemon source `d522822a5daeb72d124360b576b82a8bb52b43f4`, executable sha256 `d0656673f7b6dfa6f2554fac18f3e0b42e2eb10f444b06f6063255335d71aa14`; SDK CLI 2.1.286 sha256 `75e3016e9d2570767b08e43a7467d4817a4f149232c169ca295f2c95fef21433`. The planned effort transport changes require a new remotely built daemon and affected acceptance reruns; they are not covered by this baseline-runtime evidence.
|
||||
@@ -12,7 +12,7 @@ export const PAPERCLIP_RUNNER_IDLE_TIMEOUT_DEFAULT_MS = 300_000;
|
||||
export const PAPERCLIP_RUNNER_IDLE_TIMEOUT_MAX_MS = 86_400_000;
|
||||
export const PAPERCLIP_RUNNER_DEFAULT_MODELS = {
|
||||
codex: "gpt-5.6-sol",
|
||||
acpx: "claude-sonnet-5",
|
||||
acpx: "claude-opus-5-5",
|
||||
opencode: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -27,9 +27,9 @@ describe("Claude model defaults", () => {
|
||||
expect(claudeLocalReasoningEffortsForModel("custom-model")).toEqual(["low", "medium", "high"]);
|
||||
});
|
||||
|
||||
it.each([undefined, null, "", " "])("uses Opus 5 for an unset model (%j)", (model) => {
|
||||
expect(DEFAULT_CLAUDE_LOCAL_MODEL).toBe("claude-opus-5");
|
||||
expect(resolveClaudeModel(model)).toBe("claude-opus-5");
|
||||
it.each([undefined, null, "", " "])("uses Opus 5.5 for an unset model (%j)", (model) => {
|
||||
expect(DEFAULT_CLAUDE_LOCAL_MODEL).toBe("claude-opus-5-5");
|
||||
expect(resolveClaudeModel(model)).toBe("claude-opus-5-5");
|
||||
});
|
||||
|
||||
it("keeps explicit model IDs ahead of environment overrides", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const DEFAULT_CLAUDE_LOCAL_MODEL = "claude-opus-5";
|
||||
export const DEFAULT_CLAUDE_LOCAL_MODEL = "claude-opus-5-5";
|
||||
|
||||
/** Resolve Paperclip's default without replacing an explicit provider model. */
|
||||
export function resolveClaudeModel(
|
||||
|
||||
@@ -43,7 +43,8 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
|
||||
ac.graceSec = 15;
|
||||
const env = buildAdapterEnvConfig(v.envBindings, v.envVars);
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
||||
// Native creation omits the legacy form default; explicit limits remain visible to validation.
|
||||
if (v.runner === "legacy" || v.maxTurnsPerRun !== 1000) ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
||||
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
||||
if (v.workspaceStrategyType === "git_worktree") {
|
||||
ac.workspaceStrategy = {
|
||||
|
||||
@@ -76,6 +76,7 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
|
||||
export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const config = buildCodexLocalConfig(v);
|
||||
const schemaValues = normalizeLegacyRunnerProvider({ ...(v.adapterSchemaValues ?? {}) });
|
||||
const configuredEffort = v.thinkingEffort || schemaValues.modelReasoningEffort || schemaValues.reasoningEffort || schemaValues.effort;
|
||||
for (const unsupportedKey of [
|
||||
"engine",
|
||||
"agentCommand",
|
||||
@@ -253,6 +254,7 @@ export function buildPaperclipRunnerConfig(v: CreateConfigValues): Record<string
|
||||
...(provider === "acpx"
|
||||
? {
|
||||
acpxAgent,
|
||||
...(acpxAgent === "claude" && configuredEffort ? { modelReasoningEffort: configuredEffort } : {}),
|
||||
...(cursorMode === undefined ? {} : { acpxSessionMode: cursorMode }),
|
||||
...(piThinkingLevel === undefined ? {} : { piThinkingLevel }),
|
||||
model: configuredModel || schemaModel || (acpxAgent === "grok" ? "grok-4.7" : resolvePaperclipRunnerModel("acpx", undefined)),
|
||||
|
||||
@@ -154,7 +154,16 @@ policy in the closed provider configuration: `approvalPolicy` for Codex and
|
||||
provider-session identity, so an incompatible idle or recovered session is
|
||||
replaced on the next execution. An active turn is never mutated in place.
|
||||
|
||||
`paperclip.native-execution-input.v5` is the current input format. It retains
|
||||
`paperclip.native-execution-input.v5` remains the ordinary input format.
|
||||
Claude inputs with explicitly configured effort use v7 with the optional
|
||||
`provider.claudeEffort` field. V7 retains the compact v3 model envelope; effort
|
||||
is host configuration, never model-visible task text. V6 remains the separate
|
||||
OpenAI Dot format. Readers retain persisted v1-v6 contracts. Claude effort is
|
||||
acknowledged on each ACP connection before prompting, bound into the session
|
||||
identity and recovery checkpoint, and cross-checked against the public provider
|
||||
descriptor. Missing effort retains the provider default; values are never clamped.
|
||||
|
||||
The v5 input format It retains
|
||||
the v4 permission pinning and adds optional completion-source references;
|
||||
`paperclip.native-model-envelope.v3` is the corresponding explicit model
|
||||
projection. Readers continue to accept persisted v1-v4 inputs, and v5 readers
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
{
|
||||
"path": "schemas/provider-descriptor.schema.json",
|
||||
"id": "https://paperclip.dev/schemas/prp/v1/provider-descriptor.schema.json",
|
||||
"sha256": "bf691bda4a58490dba680b74559bfe1c9254b19e3265a603d9cbf3e169094519"
|
||||
"sha256": "ef37ebba071ac239e0b4ff680a469eca6948b8eace451c5c88089c81b7acdae1"
|
||||
},
|
||||
{
|
||||
"path": "schemas/provider-event.schema.json",
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
},
|
||||
{
|
||||
"if": { "properties": { "command": { "const": "session.open" } } },
|
||||
"then": { "properties": { "params": { "$ref": "#/$defs/sessionOpenThinkingLevel" } } }
|
||||
"then": { "properties": { "params": { "allOf": [{ "$ref": "#/$defs/sessionOpenThinkingLevel" }, { "$ref": "#/$defs/sessionOpenClaudeEffort" }] } } }
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -89,6 +89,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"claudeEffort": { "enum": ["low", "medium", "high", "xhigh", "max"] },
|
||||
"sessionOpenClaudeEffort": {
|
||||
"type": "object",
|
||||
"properties": { "claudeEffort": { "$ref": "#/$defs/claudeEffort" } },
|
||||
"if": { "properties": { "agent": { "const": "claude" } }, "required": ["agent"] },
|
||||
"then": { "properties": { "expectedIdentity": {
|
||||
"type": ["object", "null"], "properties": { "claudeEffort": { "$ref": "#/$defs/claudeEffort" } }
|
||||
} } },
|
||||
"else": { "properties": {
|
||||
"claudeEffort": false,
|
||||
"expectedIdentity": { "type": ["object", "null"], "properties": { "claudeEffort": false } }
|
||||
} }
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"required": ["protocolVersion", "id", "ok"],
|
||||
|
||||
@@ -153,6 +153,15 @@
|
||||
"high",
|
||||
"max"
|
||||
]
|
||||
},
|
||||
"claudeEffort": {
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max"
|
||||
]
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
@@ -176,6 +185,26 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"required": [
|
||||
"claudeEffort"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"provider": {
|
||||
"const": "acpx"
|
||||
},
|
||||
"agent": {
|
||||
"const": "claude"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agent"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -143,6 +143,8 @@ struct AcpxProviderDescriptor {
|
||||
mode: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pi_thinking_level: Option<PiThinkingLevel>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
claude_effort: Option<String>,
|
||||
permission_mode_pinned: bool,
|
||||
#[serde(default)]
|
||||
provider_policy: Option<AcpxProviderRuntimePolicy>,
|
||||
@@ -267,6 +269,10 @@ impl AcpxProviderDescriptor {
|
||||
|| self.model.len() > 240
|
||||
|| self.model.contains('\0')
|
||||
|| ((self.agent == "pi") != self.pi_thinking_level.is_some())
|
||||
|| self.claude_effort.as_ref().is_some_and(|effort| {
|
||||
self.agent != "claude"
|
||||
|| !matches!(effort.as_str(), "low" | "medium" | "high" | "xhigh" | "max")
|
||||
})
|
||||
|| self.mode.as_ref().is_some_and(|mode| {
|
||||
mode.trim().is_empty()
|
||||
|| mode.chars().count() > 240
|
||||
@@ -368,6 +374,7 @@ impl AcpxProviderDescriptor {
|
||||
permission_mode: self.permission_mode,
|
||||
mode: self.mode.clone(),
|
||||
pi_thinking_level: self.pi_thinking_level,
|
||||
claude_effort: self.claude_effort.clone(),
|
||||
permission_mode_pinned: self.permission_mode_pinned,
|
||||
provider_policy: self.provider_policy.clone(),
|
||||
system_instructions: self.instructions.clone(),
|
||||
@@ -472,6 +479,9 @@ impl AcpxProviderDescriptor {
|
||||
"acpxRecordId": identity.map(|value| value.acpx_record_id.as_str()),
|
||||
"permissionMode": self.permission_mode,
|
||||
});
|
||||
if let Some(effort) = &self.claude_effort {
|
||||
descriptor["claudeEffort"] = json!(effort);
|
||||
}
|
||||
if let Some(level) = self.pi_thinking_level {
|
||||
descriptor["piThinkingLevel"] = json!(level);
|
||||
}
|
||||
@@ -611,6 +621,7 @@ impl AcpxDurableState {
|
||||
if identity.profile_digest != self.descriptor.command_digest
|
||||
|| identity.mode != self.descriptor.mode
|
||||
|| identity.pi_thinking_level != self.descriptor.pi_thinking_level
|
||||
|| identity.claude_effort != self.descriptor.claude_effort
|
||||
{
|
||||
return Err(DurableRunnerError::invalid(
|
||||
"ACPX durable identity no longer matches its qualified profile or provider mode",
|
||||
@@ -1295,6 +1306,9 @@ impl AcpxCommandExecutor {
|
||||
"sessionId": identity.agent_session_id,
|
||||
"processId": process_id,
|
||||
});
|
||||
if let Some(effort) = &identity.claude_effort {
|
||||
result["claudeEffort"] = json!(effort);
|
||||
}
|
||||
if let Some(level) = identity.pi_thinking_level {
|
||||
// This identity is admitted only after the sidecar's effective-mode ACK.
|
||||
result["piThinkingLevel"] = json!(level);
|
||||
@@ -2754,6 +2768,7 @@ mod tests {
|
||||
permission_mode: Some(descriptor.permission_mode),
|
||||
mode: descriptor.mode.clone(),
|
||||
pi_thinking_level: descriptor.pi_thinking_level,
|
||||
claude_effort: descriptor.claude_effort.clone(),
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
};
|
||||
let operations = Vec::new();
|
||||
@@ -3030,6 +3045,7 @@ mod tests {
|
||||
permission_mode: Some(AcpxPermissionMode::ApproveReads),
|
||||
mode: None,
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
};
|
||||
|
||||
@@ -3507,6 +3523,7 @@ mod tests {
|
||||
permission_mode: Some(original_descriptor.permission_mode),
|
||||
mode: original_descriptor.mode.clone(),
|
||||
pi_thinking_level: original_descriptor.pi_thinking_level,
|
||||
claude_effort: original_descriptor.claude_effort.clone(),
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
};
|
||||
let operations = Vec::new();
|
||||
@@ -3792,6 +3809,7 @@ mod tests {
|
||||
permission_mode: Some(original_descriptor.permission_mode),
|
||||
mode: original_descriptor.mode.clone(),
|
||||
pi_thinking_level: original_descriptor.pi_thinking_level,
|
||||
claude_effort: original_descriptor.claude_effort.clone(),
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
};
|
||||
let operations = Vec::new();
|
||||
@@ -4086,6 +4104,7 @@ mod tests {
|
||||
permission_mode: Some(descriptor.permission_mode),
|
||||
mode: descriptor.mode.clone(),
|
||||
pi_thinking_level: descriptor.pi_thinking_level,
|
||||
claude_effort: descriptor.claude_effort.clone(),
|
||||
provider_lifetime_fence_candidates,
|
||||
};
|
||||
let operations = Vec::new();
|
||||
@@ -4259,6 +4278,7 @@ mod tests {
|
||||
permission_mode: Some(provider_descriptor.permission_mode),
|
||||
mode: provider_descriptor.mode.clone(),
|
||||
pi_thinking_level: provider_descriptor.pi_thinking_level,
|
||||
claude_effort: provider_descriptor.claude_effort.clone(),
|
||||
provider_lifetime_fence_candidates,
|
||||
});
|
||||
state.provider_exit_unconfirmed = true;
|
||||
|
||||
@@ -53,6 +53,8 @@ struct PersistedAcpxProviderSessionIdentity {
|
||||
mode: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pi_thinking_level: Option<PiThinkingLevel>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
claude_effort: Option<String>,
|
||||
provider_lifetime_fence_candidates: [u16; 3],
|
||||
}
|
||||
|
||||
@@ -77,6 +79,7 @@ impl PersistedAcpxProviderSessionIdentity {
|
||||
permission_mode,
|
||||
mode: identity.mode,
|
||||
pi_thinking_level: identity.pi_thinking_level,
|
||||
claude_effort: identity.claude_effort.clone(),
|
||||
provider_lifetime_fence_candidates: identity.provider_lifetime_fence_candidates,
|
||||
})
|
||||
}
|
||||
@@ -95,6 +98,7 @@ impl PersistedAcpxProviderSessionIdentity {
|
||||
permission_mode: Some(self.permission_mode),
|
||||
mode: self.mode.clone(),
|
||||
pi_thinking_level: self.pi_thinking_level,
|
||||
claude_effort: self.claude_effort.clone(),
|
||||
provider_lifetime_fence_candidates: self.provider_lifetime_fence_candidates,
|
||||
}
|
||||
}
|
||||
@@ -117,6 +121,7 @@ impl AcpxSuspensionCheckpoint {
|
||||
|| identity.permission_mode != Some(config.permission_mode)
|
||||
|| identity.mode != config.mode
|
||||
|| identity.pi_thinking_level != config.pi_thinking_level
|
||||
|| identity.claude_effort != config.claude_effort
|
||||
|| config
|
||||
.expected_identity
|
||||
.as_ref()
|
||||
|
||||
@@ -72,6 +72,8 @@ pub struct AcpxProviderSessionIdentity {
|
||||
pub mode: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pi_thinking_level: Option<PiThinkingLevel>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub claude_effort: Option<String>,
|
||||
pub provider_lifetime_fence_candidates: [u16; 3],
|
||||
}
|
||||
|
||||
@@ -94,6 +96,7 @@ pub struct AcpxProviderSessionConfig {
|
||||
pub permission_mode: AcpxPermissionMode,
|
||||
pub mode: Option<String>,
|
||||
pub pi_thinking_level: Option<PiThinkingLevel>,
|
||||
pub claude_effort: Option<String>,
|
||||
pub permission_mode_pinned: bool,
|
||||
pub provider_policy: Option<AcpxProviderRuntimePolicy>,
|
||||
pub system_instructions: String,
|
||||
@@ -109,6 +112,14 @@ impl AcpxProviderSessionConfig {
|
||||
LocalRunnerError::invalid("ACPX agent must name a known immutable profile")
|
||||
})?;
|
||||
validate_text(&self.model, MAX_MODEL_CHARS, "ACPX model")?;
|
||||
if self.claude_effort.as_ref().is_some_and(|effort| {
|
||||
self.agent != "claude"
|
||||
|| !matches!(effort.as_str(), "low" | "medium" | "high" | "xhigh" | "max")
|
||||
}) {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"Claude effort must be a supported Claude-only value",
|
||||
));
|
||||
}
|
||||
if (self.agent == "pi") != self.pi_thinking_level.is_some() {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX Pi thinking level must be explicit for Pi and absent for other agents",
|
||||
@@ -188,6 +199,7 @@ impl AcpxProviderSessionConfig {
|
||||
|| expected_identity.permission_mode != Some(self.permission_mode)
|
||||
|| expected_identity.mode != self.mode
|
||||
|| expected_identity.pi_thinking_level != self.pi_thinking_level
|
||||
|| expected_identity.claude_effort != self.claude_effort
|
||||
{
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX expected identity conflicts with the requested session",
|
||||
@@ -208,6 +220,13 @@ impl AcpxProviderSessionIdentity {
|
||||
if let Some(mode) = self.mode.as_deref() {
|
||||
validate_text(mode, MAX_ID_CHARS, "ACPX provider mode")?;
|
||||
}
|
||||
if self.claude_effort.as_ref().is_some_and(|effort| {
|
||||
!matches!(effort.as_str(), "low" | "medium" | "high" | "xhigh" | "max")
|
||||
}) {
|
||||
return Err(LocalRunnerError::invalid(
|
||||
"ACPX identity contains invalid Claude effort",
|
||||
));
|
||||
}
|
||||
for (value, label) in [
|
||||
(&self.normalized_session_id, "normalized session"),
|
||||
(&self.acpx_record_id, "record"),
|
||||
@@ -1313,6 +1332,9 @@ fn session_open_params(config: &AcpxProviderSessionConfig, sidecar_tools: &[Valu
|
||||
"tools": &sidecar_tools,
|
||||
"expectedIdentity": config.expected_identity,
|
||||
});
|
||||
if let Some(effort) = &config.claude_effort {
|
||||
params["claudeEffort"] = json!(effort);
|
||||
}
|
||||
if let Some(level) = config.pi_thinking_level {
|
||||
params["piThinkingLevel"] = json!(level);
|
||||
}
|
||||
@@ -1430,6 +1452,7 @@ fn verify_open_response(
|
||||
|| identity.permission_mode != Some(config.permission_mode)
|
||||
|| identity.mode != config.mode
|
||||
|| identity.pi_thinking_level != config.pi_thinking_level
|
||||
|| identity.claude_effort != config.claude_effort
|
||||
|| config
|
||||
.expected_identity
|
||||
.as_ref()
|
||||
@@ -1755,6 +1778,7 @@ mod mode_tests {
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
mode: Some("plan".to_owned()),
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: Some(AcpxProviderRuntimePolicy { read_only: false }),
|
||||
system_instructions: String::new(),
|
||||
@@ -1782,6 +1806,7 @@ mod mode_tests {
|
||||
permission_mode: Some(AcpxPermissionMode::ApproveReads),
|
||||
mode: Some("plan".to_owned()),
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
}
|
||||
}
|
||||
@@ -1921,6 +1946,27 @@ mod mode_tests {
|
||||
identity
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_effort_is_explicit_claude_only_and_forwarded_without_clamping() {
|
||||
let mut cfg = config();
|
||||
cfg.agent = "claude".to_owned();
|
||||
for effort in ["low", "medium", "high", "xhigh", "max"] {
|
||||
cfg.claude_effort = Some(effort.to_owned());
|
||||
cfg.validate().unwrap();
|
||||
assert_eq!(
|
||||
session_open_params(&cfg, &[])["claudeEffort"],
|
||||
json!(effort)
|
||||
);
|
||||
}
|
||||
cfg.claude_effort = Some("ultra".to_owned());
|
||||
assert!(cfg.validate().is_err());
|
||||
cfg.claude_effort = Some("high".to_owned());
|
||||
cfg.agent = "codex".to_owned();
|
||||
assert!(cfg.validate().is_err());
|
||||
cfg.claude_effort = None;
|
||||
cfg.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_thinking_level_is_closed_explicit_and_pi_only() {
|
||||
for (name, level) in [
|
||||
|
||||
@@ -52,6 +52,7 @@ fn config(directory: &std::path::Path) -> AcpxProviderSessionConfig {
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
mode: None,
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: None,
|
||||
system_instructions: "Complete the supplied task.".to_owned(),
|
||||
@@ -80,6 +81,7 @@ fn identity() -> AcpxProviderSessionIdentity {
|
||||
permission_mode: Some(AcpxPermissionMode::ApproveReads),
|
||||
mode: None,
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
mode: None,
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: None,
|
||||
system_instructions: "Complete the supplied task.".to_owned(),
|
||||
|
||||
@@ -47,6 +47,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
mode: None,
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: None,
|
||||
system_instructions: "Complete the supplied task.".to_owned(),
|
||||
@@ -70,6 +71,7 @@ fn expected_identity() -> AcpxProviderSessionIdentity {
|
||||
permission_mode: Some(AcpxPermissionMode::ApproveReads),
|
||||
mode: None,
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
provider_lifetime_fence_candidates: [60_001, 60_002, 60_003],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
mode: None,
|
||||
pi_thinking_level: None,
|
||||
claude_effort: None,
|
||||
permission_mode_pinned: true,
|
||||
provider_policy: None,
|
||||
system_instructions: "Complete the supplied task.".to_owned(),
|
||||
|
||||
@@ -50,6 +50,7 @@ fn config(mode: &str) -> AcpxProviderSessionConfig {
|
||||
working_directory: std::env::temp_dir(),
|
||||
permission_mode: AcpxPermissionMode::ApproveReads,
|
||||
mode: None,
|
||||
claude_effort: None,
|
||||
pi_thinking_level: if mode.starts_with("controls") {
|
||||
Some(paperclip_runner_core::acpx_provider_session::PiThinkingLevel::Low)
|
||||
} else {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
|
||||
export interface CodexAcpxNativeSessionBackendOptions extends Omit<
|
||||
CodexAcpxDriverOptions,
|
||||
"model" | "permissionMode" | "mode" | "piThinkingLevel" | "systemInstructions" | "providerPolicy" | "runtimeContext"
|
||||
"model" | "permissionMode" | "mode" | "piThinkingLevel" | "claudeEffort" | "systemInstructions" | "providerPolicy" | "runtimeContext"
|
||||
> {}
|
||||
|
||||
export type AcpxNativeSessionBackendOptions =
|
||||
@@ -76,6 +76,7 @@ export function createAcpxNativeSessionBackend(
|
||||
permissionMode: input.provider.permissionMode ?? "approve-reads",
|
||||
mode: input.provider.mode,
|
||||
piThinkingLevel: input.provider.piThinkingLevel,
|
||||
claudeEffort: "claudeEffort" in input.provider ? input.provider.claudeEffort : undefined,
|
||||
systemInstructions,
|
||||
runtimeContext: "runtimeContext" in input ? input.runtimeContext : null,
|
||||
providerPolicy: { readOnly: "executionMode" in input && input.executionMode === "plan" },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { resolveClaudeEffort } from "../drivers/acpx/claude-effort.js";
|
||||
import { parseProviderMode } from "../contracts/provider-mode.js";
|
||||
import { acpxProfileActivity, type AcpxActivityAdapter, type AcpxToolEvidence } from "../drivers/acpx/profile-activity.js";
|
||||
|
||||
@@ -297,6 +298,7 @@ async function dispatch(
|
||||
permissionMode: params.permissionMode,
|
||||
mode: params.mode,
|
||||
piThinkingLevel: params.piThinkingLevel,
|
||||
claudeEffort: params.claudeEffort,
|
||||
providerPolicy: params.providerPolicy,
|
||||
systemInstructions: params.systemInstructions,
|
||||
runtimeContext: params.runtimeContext,
|
||||
@@ -1209,12 +1211,13 @@ function safeOutput(value: unknown): Record<string, unknown> {
|
||||
function parseOpenParams(
|
||||
value: Record<string, unknown>,
|
||||
): AcpxSidecarOpenParams {
|
||||
const fields = new Set(["runtimeDirectory", "normalizedSessionId", "workingDirectory", "agent", "model", "permissionMode", "mode", "piThinkingLevel", "permissionModePinned", "providerPolicy", "systemInstructions", "runtimeContext", "tools", "providerSessionKey", "expectedIdentity"]);
|
||||
const fields = new Set(["runtimeDirectory", "normalizedSessionId", "workingDirectory", "agent", "model", "permissionMode", "mode", "piThinkingLevel", "claudeEffort", "permissionModePinned", "providerPolicy", "systemInstructions", "runtimeContext", "tools", "providerSessionKey", "expectedIdentity"]);
|
||||
if (Object.keys(value).some(key => !fields.has(key))) throw new Error("ACPX open parameters include an unsupported field");
|
||||
const agent = requireQualifiedAgent(value.agent);
|
||||
const model = requiredText(value.model, "model");
|
||||
if (value.mode !== undefined && agent !== "cursor") throw new Error("mode is supported only for Cursor");
|
||||
const piThinkingLevel = resolvePiThinkingLevel(agent, value.piThinkingLevel);
|
||||
const claudeEffort = resolveClaudeEffort(agent, value.claudeEffort);
|
||||
resolveQualifiedAcpxProfile(agent, model);
|
||||
if (
|
||||
value.providerSessionKey !== undefined &&
|
||||
@@ -1236,6 +1239,7 @@ function parseOpenParams(
|
||||
permissionMode: requiredPermissionMode(value.permissionMode),
|
||||
...(value.mode === undefined ? {} : { mode: parseProviderMode(value.mode) }),
|
||||
...(piThinkingLevel ? { piThinkingLevel } : {}),
|
||||
...(claudeEffort ? { claudeEffort } : {}),
|
||||
permissionModePinned: value.permissionModePinned === true,
|
||||
...(value.providerPolicy == null ? {} : { providerPolicy: parseProviderPolicy(value.providerPolicy) }),
|
||||
systemInstructions: boundedText(
|
||||
@@ -1307,6 +1311,7 @@ function parseExpectedIdentity(value: unknown): AcpxExpectedSessionIdentity {
|
||||
: { permissionMode: requiredPermissionMode(input.permissionMode) }),
|
||||
...(input.mode === undefined ? {} : { mode: parseProviderMode(input.mode) }),
|
||||
...(input.piThinkingLevel === undefined ? {} : { piThinkingLevel: resolvePiThinkingLevel("pi", input.piThinkingLevel) }),
|
||||
...(input.claudeEffort === undefined ? {} : { claudeEffort: resolveClaudeEffort("claude", input.claudeEffort) }),
|
||||
providerLifetimeFenceCandidates: requiredFenceCandidates(
|
||||
input.providerLifetimeFenceCandidates,
|
||||
),
|
||||
|
||||
@@ -457,6 +457,7 @@ export interface AcpxSessionIdentity {
|
||||
/** Effective provider mode identifier, bound to the session identity. */
|
||||
mode?: string;
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
providerLifetimeFenceCandidates: readonly [number, number, number];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
||||
import { QUALIFIED_ACPX_PROFILES } from "../drivers/acpx/qualified-profiles.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildNativeModelEnvelope, parseNativeExecutionInput, NATIVE_EXECUTION_INPUT_SCHEMA, NATIVE_EXECUTION_INPUT_SCHEMA_V6, type NativeExecutionInputV1 } from "./native-execution.js";
|
||||
import { buildNativeModelEnvelope, parseNativeExecutionInput, NATIVE_EXECUTION_INPUT_SCHEMA, NATIVE_EXECUTION_INPUT_SCHEMA_V6, NATIVE_EXECUTION_INPUT_SCHEMA_V7, type NativeExecutionInputV1 } from "./native-execution.js";
|
||||
import {
|
||||
NATIVE_RUNTIME_ASSET_SCHEMA,
|
||||
PAPERCLIP_EXECUTION_PROMPT,
|
||||
@@ -680,3 +680,30 @@ describe("native task context ownership", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("Claude effort input v7", () => {
|
||||
const { permissionPolicy: _permission, qualificationModel: _model, reportedModelId: _reported, ...profile } = QUALIFIED_ACPX_PROFILES.claude;
|
||||
const digest = "0".repeat(64);
|
||||
const context = { prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
|
||||
instructions: { entryPath: "AGENTS.md", bundle: { schema: NATIVE_RUNTIME_ASSET_SCHEMA, digest, manifestDigest: digest, rootPath: "/runtime/instructions", fileCount: 1, totalBytes: 1 } },
|
||||
skills: [], mcp: { assignmentSetId: "none", digest, bindingId: null } };
|
||||
const current = { ...input, schema: NATIVE_EXECUTION_INPUT_SCHEMA_V7, executionMode: "default", planningContext: null,
|
||||
runtimeContext: { ...context, aggregateDigest: canonicalNativeRuntimeContextDigest(context) },
|
||||
session: { ...input.session, driverKind: "acpx_runtime" },
|
||||
provider: { kind: "acpx", agent: "claude", model: "claude-opus-5-5", permissionMode: "approve-all", profile } };
|
||||
it.each(["low", "medium", "high", "xhigh", "max"])("round trips explicit %s and retains the compact model envelope", claudeEffort => {
|
||||
const parsed = parseNativeExecutionInput({ ...current, provider: { ...current.provider, claudeEffort } });
|
||||
expect(parsed).toMatchObject({ schema: NATIVE_EXECUTION_INPUT_SCHEMA_V7, provider: { claudeEffort } });
|
||||
expect(buildNativeModelEnvelope(parsed).schema).toBe("paperclip.native-model-envelope.v3");
|
||||
expect(JSON.stringify(buildNativeModelEnvelope(parsed))).not.toContain("claudeEffort");
|
||||
});
|
||||
it("keeps historical readers closed and rejects aliases and foreign effort", () => {
|
||||
for (const schema of ["paperclip.native-execution-input.v4", NATIVE_EXECUTION_INPUT_SCHEMA]) {
|
||||
expect(() => parseNativeExecutionInput({ ...current, schema, provider: { ...current.provider, claudeEffort: "high" } })).toThrow("claudeEffort");
|
||||
expect(parseNativeExecutionInput({ ...current, schema }).schema).toBe(schema);
|
||||
}
|
||||
expect(() => parseNativeExecutionInput({ ...current, provider: { ...current.provider, claudeEffort: "ultra" } })).toThrow("Claude effort");
|
||||
expect(() => parseNativeExecutionInput({ ...current, provider: { ...current.provider, agent: "codex", profile: QUALIFIED_ACPX_PROFILES.codex, claudeEffort: "high" } })).toThrow("Claude effort");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveClaudeEffort } from "../drivers/acpx/claude-effort.js";
|
||||
import type { DotBindingSnapshot } from "./external-provider.js";
|
||||
import { QUALIFIED_ACPX_VERSION } from "../drivers/acpx/generated-profiles.js";
|
||||
import { isSupportedAcpxProfileVersion, type AcpxProfileVersion } from "../drivers/acpx/profile-compatibility.js";
|
||||
@@ -9,6 +10,7 @@ import { explicitTaskSkillNames, parseNativeRuntimeContext, type NativeRuntimeCo
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V1 = "paperclip.native-execution-input.v1" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V2 = "paperclip.native-execution-input.v2" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V3 = "paperclip.native-execution-input.v3" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V7 = "paperclip.native-execution-input.v7" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V6 = "paperclip.native-execution-input.v6" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA_V4 = "paperclip.native-execution-input.v4" as const;
|
||||
export const NATIVE_EXECUTION_INPUT_SCHEMA = "paperclip.native-execution-input.v5" as const;
|
||||
@@ -238,6 +240,13 @@ export interface NativeExecutionInputV5 extends Omit<NativeExecutionInputV4, "sc
|
||||
completionSources?: NativeCompletionSources;
|
||||
}
|
||||
|
||||
export type NativeProviderConfigV7 = Exclude<NativeProviderConfigV5, { kind: "acpx" }>
|
||||
| (Extract<NativeProviderConfigV5, { kind: "acpx" }> & { claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max" });
|
||||
export interface NativeExecutionInputV7 extends Omit<NativeExecutionInputV5, "schema" | "provider"> {
|
||||
schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V7;
|
||||
provider: NativeProviderConfigV7;
|
||||
}
|
||||
|
||||
export interface NativeExecutionInputV6 extends Omit<NativeExecutionInputV5, "schema" | "provider" | "workspace" | "session"> {
|
||||
schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V6;
|
||||
provider: { kind: "openai_dot"; model: null; binding: DotBindingSnapshot };
|
||||
@@ -245,7 +254,7 @@ export interface NativeExecutionInputV6 extends Omit<NativeExecutionInputV5, "sc
|
||||
session: Omit<NativeExecutionInputV5["session"], "driverKind"> & { driverKind: "openai_dot_mcp" };
|
||||
}
|
||||
|
||||
export type NativeExecutionInput = NativeExecutionInputV1 | NativeExecutionInputV2 | NativeExecutionInputV3 | NativeExecutionInputV4 | NativeExecutionInputV5 | NativeExecutionInputV6;
|
||||
export type NativeExecutionInput = NativeExecutionInputV1 | NativeExecutionInputV2 | NativeExecutionInputV3 | NativeExecutionInputV4 | NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7;
|
||||
|
||||
/** The only task data that may enter provider-visible model input. */
|
||||
export interface NativeModelEnvelopeV1 {
|
||||
@@ -372,7 +381,8 @@ function parseDotNativeExecutionInput(input: Record<string, unknown>): NativeExe
|
||||
export function parseNativeExecutionInput(value: unknown): NativeExecutionInput {
|
||||
const input = record(value, "input");
|
||||
if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6) return parseDotNativeExecutionInput(input);
|
||||
const isV5 = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA;
|
||||
const isV7 = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7;
|
||||
const isV5 = isV7 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA;
|
||||
const isV4 = isV5 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V4;
|
||||
const isV3 = isV4 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V3;
|
||||
const isV2 = isV3 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V2;
|
||||
@@ -504,7 +514,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
: provider.kind === "aws_agentcore"
|
||||
? ["kind", "model", "agentCoreProfile", "maxEstimatedSessionCostUsd", "invocationLimits"]
|
||||
: provider.kind === "acpx"
|
||||
? ["kind", "agent", "model", isV4 ? "permissionMode" : "permissionPolicy", "profile", "piThinkingLevel", ...(isV4 ? ["mode"] : [])]
|
||||
? ["kind", "agent", "model", isV4 ? "permissionMode" : "permissionPolicy", "profile", "piThinkingLevel", ...(isV4 ? ["mode"] : []), ...(isV7 ? ["claudeEffort"] : [])]
|
||||
: provider.kind === "codex" && isV4
|
||||
? ["kind", "model", "approvalPolicy", ...(isV5 ? ["reasoningEffort"] : [])]
|
||||
: provider.kind === "opencode" && isV4
|
||||
@@ -527,7 +537,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
if (provider.kind === "opencode" && (providerModel === null || !providerModel.includes("/"))) {
|
||||
throw new NativeExecutionInputError("input.provider.model is required for opencode in provider/model form");
|
||||
}
|
||||
let parsedProvider: NativeProviderConfig | NativeProviderConfigV5;
|
||||
let parsedProvider: NativeProviderConfigV7 | NativeProviderConfig | NativeProviderConfigV5;
|
||||
if (provider.kind === "claude_managed") {
|
||||
if (providerModel === null) {
|
||||
throw new NativeExecutionInputError("input.provider.model is required for claude_managed");
|
||||
@@ -643,6 +653,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
} else if (provider.permissionPolicy !== "interactive") {
|
||||
throw new NativeExecutionInputError("input.provider.permissionPolicy must be interactive");
|
||||
}
|
||||
const claudeEffort = resolveClaudeEffort(provider.agent, provider.claudeEffort);
|
||||
const profile = record(provider.profile, "input.provider.profile");
|
||||
exactKeys(profile, [
|
||||
"driverKind",
|
||||
@@ -681,6 +692,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
? { permissionMode: provider.permissionMode as NativeAcpxPermissionMode }
|
||||
: { permissionPolicy: "interactive" as const }),
|
||||
...(provider.mode === undefined ? {} : { mode: provider.mode as string }),
|
||||
...(claudeEffort === undefined ? {} : { claudeEffort }),
|
||||
...(provider.piThinkingLevel === undefined ? {} : { piThinkingLevel: provider.piThinkingLevel as "off" | "low" | "high" | "max" }),
|
||||
profile: {
|
||||
driverKind: "acpx_runtime",
|
||||
@@ -835,6 +847,12 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
provider: parsedProvider as NativeProviderConfigV4,
|
||||
};
|
||||
if (!isV5) return { ...withPermissions, schema: NATIVE_EXECUTION_INPUT_SCHEMA_V4 };
|
||||
if (isV7) return {
|
||||
...withPermissions,
|
||||
schema: NATIVE_EXECUTION_INPUT_SCHEMA_V7,
|
||||
provider: parsedProvider as NativeProviderConfigV7,
|
||||
...(input.completionSources !== undefined ? { completionSources: parseCompletionSources(input.completionSources) } : {}),
|
||||
};
|
||||
return {
|
||||
...withPermissions,
|
||||
schema: NATIVE_EXECUTION_INPUT_SCHEMA,
|
||||
@@ -901,7 +919,7 @@ export function buildNativeModelEnvelope(input: NativeExecutionInput, options?:
|
||||
interactionResponses: structuredClone(input.interactionResponses),
|
||||
};
|
||||
}
|
||||
if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6) {
|
||||
if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V6 || input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V7) {
|
||||
const sourceBinding = input.completionSources;
|
||||
const references = sourceBinding?.contractRevision === input.completionContract.contract.revision
|
||||
&& sourceBinding.promptSha256 === createHash("sha256").update(input.task.prompt).digest("hex")
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { admittedClaudeEffort, createClaudeEffortAdmission, resolveClaudeEffort } from "./claude-effort.js";
|
||||
|
||||
const response = (value: string, sessionId?: string) => ({ ...(sessionId ? { sessionId } : {}), configOptions: [{ id: "effort", currentValue: value }] });
|
||||
function loaded(method = "session/new") {
|
||||
const admission = createClaudeEffortAdmission("high");
|
||||
const guard = admission.createGuard();
|
||||
guard("outbound", { id: 1, method, params: method === "session/load" ? { sessionId: "s" } : {} });
|
||||
guard("inbound", { id: 1, result: response("medium", method === "session/new" ? "s" : undefined) });
|
||||
return { admission, guard };
|
||||
}
|
||||
function select(guard: ReturnType<ReturnType<typeof createClaudeEffortAdmission>["createGuard"]>, acknowledged = "high") {
|
||||
guard("outbound", { id: 2, method: "session/set_config_option", params: { sessionId: "s", configId: "effort", value: "high" } });
|
||||
guard("inbound", { id: 2, result: response(acknowledged) });
|
||||
}
|
||||
describe("Claude effort admission", () => {
|
||||
it.each(["low", "medium", "high", "xhigh", "max"] as const)("accepts exact %s without aliases", value => {
|
||||
expect(resolveClaudeEffort("claude", value)).toBe(value);
|
||||
expect(admittedClaudeEffort("claude", value, { claudeEffort: value }, { claudeEffort: value })).toBe(value);
|
||||
});
|
||||
it("rejects unsupported, foreign and conflicting effort", () => {
|
||||
for (const value of ["ultra", "off", null, 2]) expect(() => resolveClaudeEffort("claude", value)).toThrow();
|
||||
expect(() => resolveClaudeEffort("pi", "high")).toThrow("only supported");
|
||||
expect(() => admittedClaudeEffort("claude", "high", { claudeEffort: "high" }, { claudeEffort: "medium" })).toThrow("differs");
|
||||
});
|
||||
it.each(["session/new", "session/load"])("requires acknowledged effort before prompting after %s", method => {
|
||||
const { admission, guard } = loaded(method);
|
||||
expect(admission.isReady()).toBe(false);
|
||||
select(guard);
|
||||
expect(admission.isReady()).toBe(true);
|
||||
expect(() => guard("outbound", { id: 3, method: "session/prompt", params: { sessionId: "s" } })).not.toThrow();
|
||||
});
|
||||
it("blocks a prompt based only on a restored default", () => {
|
||||
const { guard } = loaded("session/load");
|
||||
expect(() => guard("outbound", { id: 3, method: "session/prompt", params: { sessionId: "s" } })).toThrow("does not acknowledge");
|
||||
});
|
||||
it("rejects a provider that ignores the requested effort", () => {
|
||||
const { guard } = loaded();
|
||||
expect(() => select(guard, "medium")).toThrow("did not apply");
|
||||
});
|
||||
it("rejects drift and wrong-session prompts after admission", () => {
|
||||
const { guard } = loaded(); select(guard);
|
||||
expect(() => guard("inbound", { method: "session/update", params: { sessionId: "s", update: { sessionUpdate: "config_option_update", ...response("low") } } })).toThrow("drifted");
|
||||
const fresh = loaded(); select(fresh.guard);
|
||||
expect(() => fresh.guard("outbound", { id: 3, method: "session/prompt", params: { sessionId: "foreign" } })).toThrow("different session");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createAcpxConfigAdmission } from "./config-admission.js";
|
||||
|
||||
export type ClaudeEffort = "low" | "medium" | "high" | "xhigh" | "max";
|
||||
export function resolveClaudeEffort(agent: string, value: unknown): ClaudeEffort | undefined {
|
||||
if (value === undefined || value === "") return undefined;
|
||||
if (agent !== "claude") throw new Error("Claude effort is only supported by Claude");
|
||||
if (value !== "low" && value !== "medium" && value !== "high" && value !== "xhigh" && value !== "max") throw new Error("Claude effort must be low, medium, high, xhigh, or max");
|
||||
return value;
|
||||
}
|
||||
export function createClaudeEffortAdmission(expected: ClaudeEffort) {
|
||||
// Every connection, including session/load, must select and acknowledge the
|
||||
// configured effort. A restored preference alone never admits a prompt.
|
||||
return createAcpxConfigAdmission(expected, { label: "Claude effort", configId: "effort",
|
||||
supportedValues: ["default", "low", "medium", "high", "xhigh", "max"] });
|
||||
}
|
||||
export function admittedClaudeEffort(agent: string, requested: unknown, descriptor: Record<string, unknown>, identity: Record<string, unknown>): ClaudeEffort | undefined {
|
||||
const expected = resolveClaudeEffort(agent, requested);
|
||||
const effective = resolveClaudeEffort(agent, identity.claudeEffort);
|
||||
if (effective !== expected || descriptor.claudeEffort !== expected) throw new Error("Claude effort differs from the admitted configuration");
|
||||
return effective;
|
||||
}
|
||||
@@ -148,6 +148,7 @@ export interface CodexAcpxDriverOptions {
|
||||
permissionMode?: NativeAcpxPermissionMode;
|
||||
mode?: string;
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
providerPolicy?: { readOnly: boolean };
|
||||
runtimeContext?: OpenAcpxRuntimeHostOptions["runtimeContext"];
|
||||
systemInstructions?: string;
|
||||
@@ -463,6 +464,7 @@ export class CodexAcpxDriver implements HarnessDriver {
|
||||
permissionMode: this.#options.permissionMode ?? "approve-all",
|
||||
mode: this.#options.mode,
|
||||
piThinkingLevel: this.#options.piThinkingLevel,
|
||||
claudeEffort: this.#options.claudeEffort,
|
||||
providerPolicy: this.#options.providerPolicy,
|
||||
runtimeContext: this.#options.runtimeContext,
|
||||
systemInstructions: this.#options.systemInstructions,
|
||||
@@ -1290,6 +1292,7 @@ class CodexAcpxSession implements HarnessSession {
|
||||
permissionMode: identity.permissionMode,
|
||||
...(identity.mode === undefined ? {} : { mode: identity.mode }),
|
||||
...(identity.piThinkingLevel === undefined ? {} : { piThinkingLevel: identity.piThinkingLevel }),
|
||||
...(identity.claudeEffort === undefined ? {} : { claudeEffort: identity.claudeEffort }),
|
||||
providerLifetimeFenceCandidates:
|
||||
identity.providerLifetimeFenceCandidates,
|
||||
},
|
||||
@@ -2153,6 +2156,7 @@ function validateRecoverySnapshot(snapshot: PersistedHarnessSession): void {
|
||||
identity.permissionMode,
|
||||
)) ||
|
||||
(identity.mode !== undefined && !isProviderMode(identity.mode)) ||
|
||||
(identity.claudeEffort !== undefined && !["low", "medium", "high", "xhigh", "max"].includes(identity.claudeEffort)) ||
|
||||
(identity.piThinkingLevel !== undefined && !["off", "low", "high", "max"].includes(identity.piThinkingLevel)) ||
|
||||
!validProviderLifetimeFenceCandidates(
|
||||
identity.providerLifetimeFenceCandidates,
|
||||
|
||||
@@ -29,6 +29,31 @@ const HANDLE: AcpRuntimeHandle = {
|
||||
};
|
||||
|
||||
describe("Codex ACPX runtime adapter", () => {
|
||||
it.each([false, true])("acknowledges configured Claude effort before admission (restoring=%s)", async restoring => {
|
||||
const runtime = fakeRuntime();
|
||||
const options = openOptions(fakeCommand());
|
||||
options.profile = { ...options.profile, agent: "claude" };
|
||||
options.claudeEffort = "high";
|
||||
options.restoringSession = restoring;
|
||||
options.refreshConsumedCommand = vi.fn(async () => undefined);
|
||||
let created!: AcpRuntimeOptions;
|
||||
vi.mocked(runtime.setConfigOption).mockImplementation(async input => {
|
||||
const guard = created.protocolGuardFactory!();
|
||||
guard("outbound", { id: 0, method: "session/load", params: { sessionId: "backend-1" } });
|
||||
guard("inbound", { id: 0, result: { configOptions: [{ id: "effort", currentValue: "default" }] } });
|
||||
guard("outbound", { id: 1, method: "session/set_config_option", params: { sessionId: "backend-1", configId: input.key, value: input.value } });
|
||||
guard("inbound", { id: 1, result: { configOptions: [{ id: "effort", currentValue: input.value }] } });
|
||||
});
|
||||
const port = await openCodexAcpxRuntime(options, {
|
||||
createRegistry: () => registry(), createStore: () => store(), createRuntime: value => { created = value; return runtime; },
|
||||
});
|
||||
expect(runtime.setConfigOption).toHaveBeenCalledExactlyOnceWith({ handle: HANDLE, key: "effort", value: "high" });
|
||||
expect(await port.identity()).toMatchObject({ claudeEffort: "high" });
|
||||
expect(options.refreshConsumedCommand).toHaveBeenCalledOnce();
|
||||
expect(runtime.startTurn).not.toHaveBeenCalled();
|
||||
await port.close({ reason: "test complete" });
|
||||
});
|
||||
|
||||
it("installs per-connection policy authority only for Copilot and rejects native commands before a turn starts", async () => {
|
||||
for (const agent of ["copilot", "codex", "claude", "grok"] as const) {
|
||||
const runtime = fakeRuntime();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveClaudeEffort, createClaudeEffortAdmission } from "./claude-effort.js";
|
||||
import { createPiThinkingAdmission, resolvePiThinkingLevel } from "./pi-thinking.js";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
@@ -305,6 +306,8 @@ export async function openQualifiedAcpxRuntime(
|
||||
const modeBinding = createAcpxModeBinding(options.profile.agent, options.mode);
|
||||
const selectedPiThinkingLevel = resolvePiThinkingLevel(options.profile.agent, options.piThinkingLevel);
|
||||
const piThinking = selectedPiThinkingLevel ? createPiThinkingAdmission(selectedPiThinkingLevel, { restoring: options.restoringSession }) : undefined;
|
||||
const selectedClaudeEffort = resolveClaudeEffort(options.profile.agent, options.claudeEffort);
|
||||
const claudeEffort = selectedClaudeEffort ? createClaudeEffortAdmission(selectedClaudeEffort) : undefined;
|
||||
const cursorInstructions = options.profile.agent === "cursor"
|
||||
? createCursorInstructionAdmission(options.systemInstructions)
|
||||
: null;
|
||||
@@ -316,6 +319,7 @@ export async function openQualifiedAcpxRuntime(
|
||||
return (direction: "inbound" | "outbound", message: unknown) => { instructions?.(direction, message); mode?.(direction, message); };
|
||||
} } : {}),
|
||||
...(piThinking ? { protocolGuardFactory: () => piThinking.createGuard() } : {}),
|
||||
...(claudeEffort ? { protocolGuardFactory: () => claudeEffort.createGuard() } : {}),
|
||||
sessionStore,
|
||||
agentRegistry: createRegistry({
|
||||
// Preserve Claude's ACP capability identity. This is metadata only: the
|
||||
@@ -527,6 +531,18 @@ export async function openQualifiedAcpxRuntime(
|
||||
await commandLaunches.refreshConsumedCommand?.();
|
||||
}
|
||||
return ensuredHandle;
|
||||
}) : claudeEffort ? ensuredSession.then(async (ensuredHandle) => {
|
||||
handle = ensuredHandle;
|
||||
options.signal?.throwIfAborted();
|
||||
if (!claudeEffort.isReady()) {
|
||||
if (!runtime.setConfigOption) throw new Error("Claude effort admission requires native configuration");
|
||||
await runtime.setConfigOption({ handle: ensuredHandle, key: "effort", value: selectedClaudeEffort! });
|
||||
claudeEffort.assertReady();
|
||||
await children.verifyLifetimeOwnership();
|
||||
options.signal?.throwIfAborted();
|
||||
await commandLaunches.refreshConsumedCommand?.();
|
||||
}
|
||||
return ensuredHandle;
|
||||
}) : ensuredSession)
|
||||
.catch((error: unknown) => {
|
||||
throw classifySessionEnsureFailure(error);
|
||||
@@ -543,6 +559,7 @@ export async function openQualifiedAcpxRuntime(
|
||||
cursorInstructions?.assertReady();
|
||||
modeBinding?.assertReady();
|
||||
piThinking?.assertReady();
|
||||
claudeEffort?.assertReady();
|
||||
// A provider can answer only after the verified sentinel is armed, but do
|
||||
// not admit the session until the owner has observed that exact handoff.
|
||||
await children.verifyLifetimeOwnership();
|
||||
@@ -597,7 +614,7 @@ export async function openQualifiedAcpxRuntime(
|
||||
return runtimePort(
|
||||
runtime,
|
||||
handle,
|
||||
{ ...requireIdentity(handle), ...(modeBinding ? { mode: modeBinding.selectedMode } : {}), ...(selectedPiThinkingLevel ? { piThinkingLevel: selectedPiThinkingLevel } : {}) },
|
||||
{ ...requireIdentity(handle), ...(modeBinding ? { mode: modeBinding.selectedMode } : {}), ...(selectedPiThinkingLevel ? { piThinkingLevel: selectedPiThinkingLevel } : {}), ...(selectedClaudeEffort ? { claudeEffort: selectedClaudeEffort } : {}) },
|
||||
baseStore,
|
||||
children,
|
||||
runtimeCloseTimeoutMs,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
/** Native mode is independent of permissions. Every connection must prove its
|
||||
* current mode before a prompt; a persisted ACPX preference is not proof. */
|
||||
export function createAcpxConfigAdmission(expected: string, options: { label: string; configId: string; supportedValues: readonly string[]; restoring?: boolean; sessionMode?: boolean }) {
|
||||
type State = { sessionId: string | null; mode: string | null; error: Error | null };
|
||||
let current: State | null = null;
|
||||
let selectedOnce = options.restoring === true;
|
||||
const failure = (detail: string) => new Error(`${options.label} admission failed: ${detail}`);
|
||||
const assertReady = () => {
|
||||
if (current?.error) throw current.error;
|
||||
if (!selectedOnce || !current?.sessionId || current.mode !== expected) throw failure("native mode does not acknowledge the selected mode");
|
||||
};
|
||||
const readMode = (result: Record<string, unknown>, session: boolean): string => {
|
||||
const configs = Array.isArray(result.configOptions) ? result.configOptions.filter(value => object(value).id === options.configId) : [];
|
||||
if (configs.length !== 1) throw failure("native mode configuration is missing or ambiguous");
|
||||
const mode = object(configs[0]).currentValue;
|
||||
// Before exact model selection, Pi can legitimately report a different
|
||||
// native model's level. Observation alone never admits the first prompt.
|
||||
if (typeof mode !== "string" || !options.supportedValues.includes(mode)) throw failure("native mode is unsupported");
|
||||
if (session && options.sessionMode && object(result.modes).currentModeId !== mode) throw failure("native mode acknowledgements conflict");
|
||||
return mode;
|
||||
};
|
||||
return {
|
||||
assertReady,
|
||||
isReady() { assertNoError(); return selectedOnce && current?.sessionId != null && current.mode === expected; },
|
||||
createGuard() {
|
||||
const state: State = { sessionId: null, mode: null, error: null };
|
||||
current = state;
|
||||
const pending = new Map<string | number, { method: string; sessionId: unknown; selectsMode: boolean }>();
|
||||
return (direction: "inbound" | "outbound", value: unknown): void => {
|
||||
if (current !== state) throw failure("connection authority was replaced");
|
||||
assertNoError();
|
||||
const message = object(value);
|
||||
const params = object(message.params);
|
||||
try {
|
||||
if (direction === "outbound") {
|
||||
const method = message.method;
|
||||
if (method === "session/prompt") {
|
||||
assertReady();
|
||||
if (params.sessionId !== state.sessionId) throw failure("prompt belongs to a different session");
|
||||
} else if (method === "session/new" || method === "session/load" || method === "session/set_config_option") {
|
||||
if (typeof message.id !== "string" && typeof message.id !== "number") throw failure("uncorrelated mode request");
|
||||
if (pending.has(message.id)) throw failure("duplicate mode request identity");
|
||||
if (pending.size) throw failure("overlapping native admission controls");
|
||||
if (method === "session/new" || method === "session/load") {
|
||||
if (pending.size) throw failure("overlapping session admission");
|
||||
state.sessionId = null; state.mode = null;
|
||||
} else {
|
||||
if (!state.sessionId || params.sessionId !== state.sessionId) throw failure("configuration belongs to a different session");
|
||||
if (params.configId === options.configId && params.value !== expected) throw failure("configuration changes the selected mode");
|
||||
state.mode = null; // No prompt may race unacknowledged native configuration.
|
||||
}
|
||||
pending.set(message.id, { method, sessionId: params.sessionId, selectsMode: params.configId === options.configId });
|
||||
} else if (options.sessionMode && method === "session/set_mode") {
|
||||
if (!state.sessionId || params.sessionId !== state.sessionId || params.modeId !== expected) throw failure("mode control changes the selected mode");
|
||||
// This method only returns {}; require a later config/session
|
||||
// acknowledgement rather than treating that empty result as proof.
|
||||
state.mode = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.method === "session/update" && object(params.update).sessionUpdate === "config_option_update") {
|
||||
const update = object(params.update);
|
||||
// Config updates may contain only model options. A mode entry is
|
||||
// authoritative observation, but never an admission acknowledgement.
|
||||
if (Array.isArray(update.configOptions) && update.configOptions.some(value => object(value).id === options.configId)) {
|
||||
if (state.sessionId && params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session");
|
||||
const observed = readMode(update, false);
|
||||
if (selectedOnce && observed !== expected) throw failure("native mode drifted from the selected mode");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (options.sessionMode && message.method === "session/update" && object(params.update).sessionUpdate === "current_mode_update") {
|
||||
if (state.sessionId && params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session");
|
||||
if (selectedOnce && object(params.update).currentModeId !== expected) throw failure("native mode drifted from the selected mode");
|
||||
return; // Notifications alone never admit a prompt.
|
||||
}
|
||||
if (message.method !== undefined || (typeof message.id !== "string" && typeof message.id !== "number")) return;
|
||||
const request = pending.get(message.id);
|
||||
if (!request) return;
|
||||
pending.delete(message.id);
|
||||
if (message.error !== undefined) { state.mode = null; return; }
|
||||
const result = object(message.result);
|
||||
const session = request.method !== "session/set_config_option";
|
||||
const sessionId = request.method === "session/new" ? result.sessionId : request.sessionId;
|
||||
if (typeof sessionId !== "string" || !sessionId.trim()) throw failure("mode acknowledgement has no session identity");
|
||||
const mode = readMode(result, session);
|
||||
if (session && selectedOnce && mode !== expected) throw failure("restored native mode drifted from the selected mode");
|
||||
if (!session && (request.selectsMode || selectedOnce) && mode !== expected) throw failure("configuration did not apply the selected mode");
|
||||
if (!session && request.selectsMode) selectedOnce = true;
|
||||
state.sessionId = sessionId; state.mode = mode;
|
||||
} catch (error) {
|
||||
state.error = error instanceof Error ? error : failure("invalid mode acknowledgement");
|
||||
throw state.error;
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
function assertNoError() { if (current?.error) throw current.error; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createAcpxConfigAdmission } from "./config-admission.js";
|
||||
|
||||
export type PiThinkingLevel = "off" | "low" | "high" | "max";
|
||||
|
||||
export function resolvePiThinkingLevel(agent: string, mode: unknown): PiThinkingLevel | undefined {
|
||||
@@ -10,108 +12,13 @@ export function resolvePiThinkingLevel(agent: string, mode: unknown): PiThinking
|
||||
return mode;
|
||||
}
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
export function createPiThinkingAdmission(expected: PiThinkingLevel, options: { restoring?: boolean } = {}) {
|
||||
return createAcpxConfigAdmission(expected, { ...options, label: "Pi thinking mode", configId: "thought_level",
|
||||
supportedValues: ["off", "minimal", "low", "medium", "high", "xhigh", "max"], sessionMode: true });
|
||||
}
|
||||
|
||||
/** Native mode is independent of permissions. Every connection must prove its
|
||||
* current mode before a prompt; a persisted ACPX preference is not proof. */
|
||||
export function createPiThinkingAdmission(expected: PiThinkingLevel, options: { restoring?: boolean } = {}) {
|
||||
type State = { sessionId: string | null; mode: string | null; error: Error | null };
|
||||
let current: State | null = null;
|
||||
let selectedOnce = options.restoring === true;
|
||||
const failure = (detail: string) => new Error(`Pi thinking mode admission failed: ${detail}`);
|
||||
const assertReady = () => {
|
||||
if (current?.error) throw current.error;
|
||||
if (!selectedOnce || !current?.sessionId || current.mode !== expected) throw failure("native mode does not acknowledge the selected mode");
|
||||
};
|
||||
const readMode = (result: Record<string, unknown>, session: boolean): string => {
|
||||
const configs = Array.isArray(result.configOptions) ? result.configOptions.filter(value => object(value).id === "thought_level") : [];
|
||||
if (configs.length !== 1) throw failure("native mode configuration is missing or ambiguous");
|
||||
const mode = object(configs[0]).currentValue;
|
||||
// Before exact model selection, Pi can legitimately report a different
|
||||
// native model's level. Observation alone never admits the first prompt.
|
||||
if (typeof mode !== "string" || !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(mode)) throw failure("native mode is unsupported");
|
||||
if (session && object(result.modes).currentModeId !== mode) throw failure("native mode acknowledgements conflict");
|
||||
return mode;
|
||||
};
|
||||
return {
|
||||
assertReady,
|
||||
isReady() { assertNoError(); return selectedOnce && current?.sessionId != null && current.mode === expected; },
|
||||
createGuard() {
|
||||
const state: State = { sessionId: null, mode: null, error: null };
|
||||
current = state;
|
||||
const pending = new Map<string | number, { method: string; sessionId: unknown; selectsMode: boolean }>();
|
||||
return (direction: "inbound" | "outbound", value: unknown): void => {
|
||||
if (current !== state) throw failure("connection authority was replaced");
|
||||
assertNoError();
|
||||
const message = object(value);
|
||||
const params = object(message.params);
|
||||
try {
|
||||
if (direction === "outbound") {
|
||||
const method = message.method;
|
||||
if (method === "session/prompt") {
|
||||
assertReady();
|
||||
if (params.sessionId !== state.sessionId) throw failure("prompt belongs to a different session");
|
||||
} else if (method === "session/new" || method === "session/load" || method === "session/set_config_option") {
|
||||
if (typeof message.id !== "string" && typeof message.id !== "number") throw failure("uncorrelated mode request");
|
||||
if (pending.has(message.id)) throw failure("duplicate mode request identity");
|
||||
if (pending.size) throw failure("overlapping native admission controls");
|
||||
if (method === "session/new" || method === "session/load") {
|
||||
if (pending.size) throw failure("overlapping session admission");
|
||||
state.sessionId = null; state.mode = null;
|
||||
} else {
|
||||
if (!state.sessionId || params.sessionId !== state.sessionId) throw failure("configuration belongs to a different session");
|
||||
if (params.configId === "thought_level" && params.value !== expected) throw failure("configuration changes the selected mode");
|
||||
state.mode = null; // No prompt may race unacknowledged native configuration.
|
||||
}
|
||||
pending.set(message.id, { method, sessionId: params.sessionId, selectsMode: params.configId === "thought_level" });
|
||||
} else if (method === "session/set_mode") {
|
||||
if (!state.sessionId || params.sessionId !== state.sessionId || params.modeId !== expected) throw failure("mode control changes the selected mode");
|
||||
// This method only returns {}; require a later config/session
|
||||
// acknowledgement rather than treating that empty result as proof.
|
||||
state.mode = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.method === "session/update" && object(params.update).sessionUpdate === "config_option_update") {
|
||||
const update = object(params.update);
|
||||
// Config updates may contain only model options. A mode entry is
|
||||
// authoritative observation, but never an admission acknowledgement.
|
||||
if (Array.isArray(update.configOptions) && update.configOptions.some(value => object(value).id === "thought_level")) {
|
||||
if (state.sessionId && params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session");
|
||||
const observed = readMode(update, false);
|
||||
if (selectedOnce && observed !== expected) throw failure("native mode drifted from the selected mode");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.method === "session/update" && object(params.update).sessionUpdate === "current_mode_update") {
|
||||
if (state.sessionId && params.sessionId !== state.sessionId) throw failure("mode update belongs to a different session");
|
||||
if (selectedOnce && object(params.update).currentModeId !== expected) throw failure("native mode drifted from the selected mode");
|
||||
return; // Notifications alone never admit a prompt.
|
||||
}
|
||||
if (message.method !== undefined || (typeof message.id !== "string" && typeof message.id !== "number")) return;
|
||||
const request = pending.get(message.id);
|
||||
if (!request) return;
|
||||
pending.delete(message.id);
|
||||
if (message.error !== undefined) { state.mode = null; return; }
|
||||
const result = object(message.result);
|
||||
const session = request.method !== "session/set_config_option";
|
||||
const sessionId = request.method === "session/new" ? result.sessionId : request.sessionId;
|
||||
if (typeof sessionId !== "string" || !sessionId.trim()) throw failure("mode acknowledgement has no session identity");
|
||||
const mode = readMode(result, session);
|
||||
if (session && selectedOnce && mode !== expected) throw failure("restored native mode drifted from the selected mode");
|
||||
if (!session && (request.selectsMode || selectedOnce) && mode !== expected) throw failure("configuration did not apply the selected mode");
|
||||
if (!session && request.selectsMode) selectedOnce = true;
|
||||
state.sessionId = sessionId; state.mode = mode;
|
||||
} catch (error) {
|
||||
state.error = error instanceof Error ? error : failure("invalid mode acknowledgement");
|
||||
throw state.error;
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
function assertNoError() { if (current?.error) throw current.error; }
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
/** Cross-check independently returned durable identity and public descriptor. */
|
||||
|
||||
@@ -75,6 +75,21 @@ describe("ACPX recovery identity", () => {
|
||||
expect(customMode.mode).toBe("provider-custom-mode");
|
||||
});
|
||||
|
||||
it("binds Claude effort to recovery and rejects changed or omitted effort", async () => {
|
||||
const fixture = await recoveryFixture();
|
||||
const profile = resolveQualifiedAcpxProfile("claude", "claude-opus-5-5");
|
||||
const input = { ...fixture.input, profile, requestedModel: profile.qualificationModel, claudeEffort: "high" as const };
|
||||
const high = await createAcpxRecoveryBinding(input);
|
||||
const low = await createAcpxRecoveryBinding({ ...input, claudeEffort: "low" });
|
||||
expect(low.profileSessionKey).not.toBe(high.profileSessionKey);
|
||||
const expected = { ...fixture.expected, profileDigest: high.commandDigest, requestedModel: high.requestedModel, effectiveModel: high.effectiveModel, claudeEffort: "high" as const };
|
||||
const saved = createAcpxIdentityRecord(expected, high);
|
||||
expect(acpxProviderSessionIdentity(saved, high).claudeEffort).toBe("high");
|
||||
expect(() => verifyExpectedAcpxIdentity({ ...expected, claudeEffort: undefined }, high, saved)).toThrow("immutable");
|
||||
expect(() => verifyExpectedAcpxIdentity(expected, high, { ...saved, claudeEffort: undefined })).toThrow("persisted");
|
||||
expect(() => verifyExpectedAcpxIdentity(expected, low, saved)).toThrow("immutable");
|
||||
});
|
||||
|
||||
it("binds Pi thinking mode and rejects old or mismatched warm identities", async () => {
|
||||
const fixture = await recoveryFixture();
|
||||
const profile = resolveQualifiedAcpxProfile("pi", "openrouter/deepseek/deepseek-v4-flash-0731");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveClaudeEffort } from "./claude-effort.js";
|
||||
import { parseProviderMode } from "../../contracts/provider-mode.js";
|
||||
import { resolvePiThinkingLevel, type PiThinkingLevel } from "./pi-thinking.js";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -23,6 +24,7 @@ export interface AcpxRecoveryBinding {
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
mode?: string;
|
||||
piThinkingLevel?: PiThinkingLevel;
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
profileSessionKey: string;
|
||||
}
|
||||
|
||||
@@ -39,6 +41,7 @@ export interface AcpxIdentityRecord {
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
mode?: string;
|
||||
piThinkingLevel?: PiThinkingLevel;
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
providerLifetimeFenceCandidates: readonly [number, number, number];
|
||||
}
|
||||
|
||||
@@ -51,12 +54,14 @@ export async function createAcpxRecoveryBinding(input: {
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
mode?: string;
|
||||
piThinkingLevel?: PiThinkingLevel;
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
providerConfigurationDigest?: string;
|
||||
providerPolicy?: { readOnly: boolean; readRoots?: readonly string[]; protectedPaths?: readonly string[] };
|
||||
}): Promise<AcpxRecoveryBinding> {
|
||||
validateIdentity(input.normalizedSessionId, "normalized session");
|
||||
const mode = parseProviderMode(input.mode);
|
||||
const piThinkingLevel = resolvePiThinkingLevel(input.profile.agent, input.piThinkingLevel);
|
||||
const claudeEffort = resolveClaudeEffort(input.profile.agent, input.claudeEffort);
|
||||
if (input.providerPolicy !== undefined && typeof input.providerPolicy.readOnly !== "boolean") throw new Error("ACPX recovery requires a valid task execution policy");
|
||||
if (input.requestedModel !== input.profile.qualificationModel) {
|
||||
throw new Error("ACPX recovery requested model does not match its admitted profile");
|
||||
@@ -104,6 +109,7 @@ export async function createAcpxRecoveryBinding(input: {
|
||||
permissionMode: input.permissionMode,
|
||||
...(mode ? { mode } : {}),
|
||||
...(piThinkingLevel ? { piThinkingLevel } : {}),
|
||||
...(claudeEffort ? { claudeEffort } : {}),
|
||||
}),
|
||||
).replace("sha256:", "paperclip-");
|
||||
return {
|
||||
@@ -118,6 +124,7 @@ export async function createAcpxRecoveryBinding(input: {
|
||||
permissionMode: input.permissionMode,
|
||||
...(mode ? { mode } : {}),
|
||||
...(piThinkingLevel ? { piThinkingLevel } : {}),
|
||||
...(claudeEffort ? { claudeEffort } : {}),
|
||||
profileSessionKey,
|
||||
};
|
||||
}
|
||||
@@ -140,6 +147,7 @@ export function createAcpxIdentityRecord(
|
||||
permissionMode: binding.permissionMode,
|
||||
...(binding.mode ? { mode: binding.mode } : {}),
|
||||
...(binding.piThinkingLevel ? { piThinkingLevel: binding.piThinkingLevel } : {}),
|
||||
...(binding.claudeEffort ? { claudeEffort: binding.claudeEffort } : {}),
|
||||
providerLifetimeFenceCandidates: Object.freeze([
|
||||
...expected.providerLifetimeFenceCandidates,
|
||||
]) as readonly [number, number, number],
|
||||
@@ -167,6 +175,7 @@ export function acpxProviderSessionIdentity(
|
||||
permissionMode: record.permissionMode,
|
||||
...(record.mode ? { mode: record.mode } : {}),
|
||||
...(record.piThinkingLevel ? { piThinkingLevel: record.piThinkingLevel } : {}),
|
||||
...(record.claudeEffort ? { claudeEffort: record.claudeEffort } : {}),
|
||||
providerLifetimeFenceCandidates: record.providerLifetimeFenceCandidates,
|
||||
};
|
||||
verifyExpectedAcpxIdentity(identity, binding, record);
|
||||
@@ -193,7 +202,8 @@ export function verifyExpectedAcpxIdentity(
|
||||
expected.effectiveModel !== binding.effectiveModel ||
|
||||
expected.permissionMode !== binding.permissionMode ||
|
||||
expected.mode !== binding.mode ||
|
||||
expected.piThinkingLevel !== binding.piThinkingLevel
|
||||
expected.piThinkingLevel !== binding.piThinkingLevel ||
|
||||
expected.claudeEffort !== binding.claudeEffort
|
||||
) {
|
||||
throw new Error(
|
||||
"ACPX recovery identity conflicts with the immutable session configuration",
|
||||
@@ -214,6 +224,7 @@ export function verifyExpectedAcpxIdentity(
|
||||
record.permissionMode !== binding.permissionMode ||
|
||||
record.mode !== binding.mode ||
|
||||
record.piThinkingLevel !== binding.piThinkingLevel ||
|
||||
record.claudeEffort !== binding.claudeEffort ||
|
||||
!sameFenceCandidates(
|
||||
record.providerLifetimeFenceCandidates,
|
||||
expected.providerLifetimeFenceCandidates,
|
||||
@@ -240,6 +251,7 @@ function parsePersistedRecord(value: unknown): AcpxIdentityRecord {
|
||||
"permissionMode",
|
||||
"mode",
|
||||
"piThinkingLevel",
|
||||
"claudeEffort",
|
||||
"providerLifetimeFenceCandidates",
|
||||
]);
|
||||
return validatedRecord(record);
|
||||
@@ -267,6 +279,7 @@ function validatedRecord(value: Record<string, unknown>): AcpxIdentityRecord {
|
||||
throw new Error("ACPX identity permission mode is invalid");
|
||||
}
|
||||
if (value.mode !== undefined) parseProviderMode(value.mode);
|
||||
resolveClaudeEffort("claude", value.claudeEffort);
|
||||
if (value.piThinkingLevel !== undefined) resolvePiThinkingLevel("pi", value.piThinkingLevel);
|
||||
validateFenceCandidates(value.providerLifetimeFenceCandidates);
|
||||
return value as unknown as AcpxIdentityRecord;
|
||||
@@ -297,6 +310,7 @@ function validateExpected(expected: AcpxExpectedSessionIdentity): void {
|
||||
throw new Error("Expected ACPX permission mode is invalid");
|
||||
}
|
||||
if (expected.mode !== undefined) parseProviderMode(expected.mode);
|
||||
resolveClaudeEffort("claude", expected.claudeEffort);
|
||||
if (expected.piThinkingLevel !== undefined) resolvePiThinkingLevel("pi", expected.piThinkingLevel);
|
||||
validateFenceCandidates(expected.providerLifetimeFenceCandidates);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveClaudeEffort } from "./claude-effort.js";
|
||||
import { piProviderConfiguration } from "./pi-provider-config.js";
|
||||
import { withAcpxTurnCancellation } from "./turn-cancellation.js";
|
||||
import { resolveAcpxProviderMode } from "./provider-mode.js";
|
||||
@@ -83,6 +84,7 @@ const ACPX_ADMISSION_CLEANUP_RESCHEDULE_MS = 1_000;
|
||||
export interface AcpxRuntimePortIdentity {
|
||||
mode?: string;
|
||||
piThinkingLevel?: PiThinkingLevel;
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
acpxRecordId: string;
|
||||
backendSessionId: string;
|
||||
agentSessionId: string;
|
||||
@@ -163,6 +165,7 @@ export interface AcpxRuntimePortOpenOptions {
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
mode?: string;
|
||||
piThinkingLevel?: PiThinkingLevel;
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
permissionPolicy: ReturnType<typeof acpxRuntimePermissionPolicy>;
|
||||
launchEnvironment: Readonly<NodeJS.ProcessEnv>;
|
||||
/** Kernel credential-home quorum inherited by the provider sentinel. */
|
||||
@@ -238,6 +241,7 @@ export interface OpenAcpxRuntimeHostOptions {
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
mode?: string;
|
||||
piThinkingLevel?: PiThinkingLevel;
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
systemInstructions?: string;
|
||||
runtimeContext?: NativeRuntimeContextSnapshot | null;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
@@ -350,6 +354,7 @@ export class AcpxRuntimeHost {
|
||||
permissionMode: options.permissionMode,
|
||||
mode: resolveAcpxProviderMode(options.agent, options.mode),
|
||||
piThinkingLevel: resolvePiThinkingLevel(options.agent, options.piThinkingLevel),
|
||||
claudeEffort: resolveClaudeEffort(options.agent, options.claudeEffort),
|
||||
...(["cursor", "copilot", "pi"].includes(options.agent) && options.providerPolicy !== undefined
|
||||
? { providerPolicy: options.providerPolicy } : {}),
|
||||
}),
|
||||
@@ -581,6 +586,7 @@ export class AcpxRuntimeHost {
|
||||
permissionMode: binding.permissionMode,
|
||||
mode: binding.mode,
|
||||
piThinkingLevel: binding.piThinkingLevel,
|
||||
claudeEffort: binding.claudeEffort,
|
||||
permissionPolicy: acpxRuntimePermissionPolicy(
|
||||
binding.permissionMode,
|
||||
),
|
||||
@@ -644,6 +650,7 @@ export class AcpxRuntimeHost {
|
||||
if (runtimeIdentity.piThinkingLevel !== binding.piThinkingLevel) {
|
||||
throw new Error("ACPX runtime Pi thinking level conflicts with session binding");
|
||||
}
|
||||
if (runtimeIdentity.claudeEffort !== binding.claudeEffort) throw new Error("ACPX runtime Claude effort conflicts with session binding");
|
||||
const observedIdentity: AcpxExpectedSessionIdentity = {
|
||||
kind: "acpx",
|
||||
normalizedSessionId: binding.normalizedSessionId,
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface AcpxSidecarOpenParams {
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
mode?: string;
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
permissionModePinned: boolean;
|
||||
providerPolicy?: { readOnly: boolean };
|
||||
systemInstructions: string;
|
||||
@@ -69,6 +70,7 @@ export interface AcpxExpectedSessionIdentity {
|
||||
permissionMode?: NativeAcpxPermissionMode;
|
||||
mode?: string;
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
providerLifetimeFenceCandidates: readonly [number, number, number];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveClaudeEffort } from "../acpx/claude-effort.js";
|
||||
import { resolvePiThinkingLevel } from "../acpx/pi-thinking.js";
|
||||
import { isProviderMode } from "../../contracts/provider-mode.js";
|
||||
import type { PersistedHarnessProviderIdentity } from "../../contracts/harness-driver.js";
|
||||
@@ -81,6 +82,7 @@ export function parseProviderIdentity(
|
||||
);
|
||||
}
|
||||
const piThinkingLevel = identity.piThinkingLevel === undefined ? undefined : resolvePiThinkingLevel("pi", identity.piThinkingLevel);
|
||||
const claudeEffort = resolveClaudeEffort("claude", identity.claudeEffort);
|
||||
const mode = identity.mode;
|
||||
if (mode !== undefined && !isProviderMode(mode)) {
|
||||
throw new Error("ACPX provider identity contains an invalid provider mode");
|
||||
@@ -112,6 +114,7 @@ export function parseProviderIdentity(
|
||||
...(permissionMode === undefined ? {} : { permissionMode }),
|
||||
...(mode === undefined ? {} : { mode }),
|
||||
...(piThinkingLevel === undefined ? {} : { piThinkingLevel }),
|
||||
...(claudeEffort === undefined ? {} : { claudeEffort }),
|
||||
providerLifetimeFenceCandidates: fenceCandidates as [
|
||||
number,
|
||||
number,
|
||||
|
||||
@@ -56,6 +56,7 @@ export {
|
||||
type QualifiedAcpxEnvironmentProbe,
|
||||
} from "./drivers/acpx/codex-acpx-driver.js";
|
||||
export * from "./drivers/acpx/sidecar-protocol.js";
|
||||
export { resolveClaudeEffort } from "./drivers/acpx/claude-effort.js";
|
||||
export * from "./drivers/runner-tool-bridge.js";
|
||||
export {
|
||||
createRunnerdCodexTransport,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { resolvePinnedOpenCodeCommand } from "../drivers/opencode/opencode-server-driver.js";
|
||||
import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js";
|
||||
import { resolveClaudeEffort, admittedClaudeEffort } from "../drivers/acpx/claude-effort.js";
|
||||
import { admittedPiThinkingLevel, resolvePiThinkingLevel } from "../drivers/acpx/pi-thinking.js";
|
||||
import { configuredEnvironment } from "../configured-environment.js";
|
||||
import { resolveAcpxProviderMode } from "../drivers/acpx/provider-mode.js";
|
||||
@@ -1191,6 +1192,7 @@ export interface CapabilityRunnerdProcessEvidence {
|
||||
providerDriver: string | null;
|
||||
/** Effective native mode, populated only from the admitted Pi identity. */
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
providerVersion: string | null;
|
||||
acpxAgent: QualifiedAcpxAgent | null;
|
||||
agentServerVersion: string | null;
|
||||
@@ -1215,6 +1217,7 @@ export interface CapabilityRunnerdCodexTransportOptions {
|
||||
acpxPermissionMode?: NativeAcpxPermissionMode;
|
||||
acpxMode?: string;
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
acpxPermissionModePinned?: boolean;
|
||||
acpxSidecarPath?: string;
|
||||
/** SHA-256 verified by the provider-pack authority before runner startup. */
|
||||
@@ -3617,6 +3620,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
readonly #bridgedRuntimeInputs = new Map<string, { durableTurnId: string; permission?: boolean }>();
|
||||
|
||||
constructor(readonly options: CapabilityRunnerdCodexTransportOptions) {
|
||||
resolveClaudeEffort(options.provider === "acpx" ? options.acpxAgent ?? "codex" : "", options.claudeEffort);
|
||||
resolvePiThinkingLevel(options.provider === "acpx" ? options.acpxAgent ?? "codex" : "", options.piThinkingLevel);
|
||||
if (options.acpxMode !== undefined && options.provider !== "acpx") {
|
||||
throw new Error("acpxMode requires the ACPX provider");
|
||||
@@ -4912,6 +4916,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
providerPolicy: { readOnly: params.permissions === "paperclip-runner-workspace-read-only" },
|
||||
...(selectedAcpxMode === undefined ? {} : { mode: selectedAcpxMode }),
|
||||
...(acpxProfile!.agent === "pi" ? { piThinkingLevel: this.options.piThinkingLevel } : {}),
|
||||
...(acpxProfile!.agent === "claude" && this.options.claudeEffort ? { claudeEffort: this.options.claudeEffort } : {}),
|
||||
permissionMode: resolveRunnerdAcpxPermissionMode(
|
||||
this.options.acpxPermissionMode,
|
||||
),
|
||||
@@ -6597,6 +6602,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
if (descriptor.turnControls !== undefined && descriptor.agent !== (this.options.acpxAgent ?? "codex")) {
|
||||
throw new Error("ACPX capability identity differs from the admitted profile");
|
||||
}
|
||||
const claudeEffort = admittedClaudeEffort(this.options.acpxAgent ?? "codex", this.options.claudeEffort, descriptor, providerIdentity);
|
||||
if (claudeEffort !== undefined) this.#evidence.claudeEffort = claudeEffort;
|
||||
const piThinkingLevel = admittedPiThinkingLevel(this.options.acpxAgent ?? "codex", this.options.piThinkingLevel, descriptor, providerIdentity);
|
||||
if (piThinkingLevel !== undefined) this.#evidence.piThinkingLevel = piThinkingLevel;
|
||||
this.#turnControls = parseAcpxTurnControlCapabilities(descriptor.turnControls, descriptor.agent);
|
||||
|
||||
@@ -1178,6 +1178,15 @@ export const providerDescriptorSchema = {
|
||||
"high",
|
||||
"max"
|
||||
]
|
||||
},
|
||||
"claudeEffort": {
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max"
|
||||
]
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
@@ -1201,6 +1210,26 @@ export const providerDescriptorSchema = {
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"required": [
|
||||
"claudeEffort"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"provider": {
|
||||
"const": "acpx"
|
||||
},
|
||||
"agent": {
|
||||
"const": "claude"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agent"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -7,13 +7,17 @@ export interface AgentRunnerAvailability {
|
||||
defaultRunner: AgentRunner;
|
||||
}
|
||||
|
||||
/** Codex is the only harness whose creation default graduates in this release. */
|
||||
export function paperclipRunnerProfileForHarness(harness: string): { provider: "codex" } | undefined {
|
||||
return harness === "codex_local" ? { provider: "codex" } : undefined;
|
||||
/** Only qualified harnesses graduate their creation default. */
|
||||
export function paperclipRunnerProfileForHarness(harness: string): { provider: "codex" } | { provider: "acpx"; acpxAgent: "claude" } | undefined {
|
||||
if (harness === "codex_local") return { provider: "codex" };
|
||||
if (harness === "claude_local") return { provider: "acpx", acpxAgent: "claude" };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Creation defaults follow the shipped Linux x64 and macOS release artifacts. */
|
||||
export function paperclipRunnerSupportsPlatform(harness: string, platform: string, architecture: string): boolean {
|
||||
// Claude graduates on Apple Silicon first; other targets await package and live qualification.
|
||||
if (harness === "claude_local") return platform === "darwin" && architecture === "arm64";
|
||||
return !!paperclipRunnerProfileForHarness(harness)
|
||||
&& ((platform === "linux" && architecture === "x64")
|
||||
|| (platform === "darwin" && (architecture === "arm64" || architecture === "x64")));
|
||||
|
||||
@@ -706,9 +706,9 @@ describe("agent routes adapter validation", () => {
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps non-Codex native provider selection behind its existing flag", async () => {
|
||||
it("keeps other native provider selection behind its existing flag", async () => {
|
||||
const app = await createApp();
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Native Claude", adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" } }));
|
||||
const response = await requestApp(app, baseUrl => request(baseUrl).post("/api/companies/company-1/agents").send({ name: "Native OpenCode", adapterType: "paperclip_runner", adapterConfig: { provider: "opencode", model: "openrouter/deepseek/deepseek-v4-flash-0731" } }));
|
||||
expect(response.status).toBe(422);
|
||||
expect(response.body.details).toMatchObject({ code: "paperclip_runner_rollout_disabled" });
|
||||
});
|
||||
|
||||
@@ -198,6 +198,9 @@ describe("durable, authorized connection routing", () => {
|
||||
expect(changed.binding).toEqual(first.binding); expect(changed.runtimeConfig).toMatchObject({ provider: "codex", model: "gpt-5.6-sol", modelReasoningEffort: "high" }); expect(changed.notes).toHaveLength(1);
|
||||
const claude = poolMemberRuntimeConfig(members[1]!, "paperclip_runner", { model: "gpt-5.6-sol", modelReasoningEffort: "ultra" });
|
||||
expect(claude.config).toMatchObject({ provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" }); expect(claude.notes).toHaveLength(2);
|
||||
const claudeEffort = poolMemberRuntimeConfig(members[1]!, "paperclip_runner", { modelReasoningEffort: "xhigh" });
|
||||
expect(claudeEffort.config).toMatchObject({ provider: "acpx", acpxAgent: "claude", modelReasoningEffort: "xhigh" });
|
||||
expect(claudeEffort.notes).toEqual([]);
|
||||
const overrides = applyAiConnectionRouterTaskSettings({ model: "claude-sonnet-5", effort: "invalid", cwd: "/fixture" }, changed);
|
||||
const native = resolvePaperclipRunnerNativeProviderInput({ backend: "codex_app_server", adapterConfig: projectPaperclipRunnerTaskConfig("codex_app_server", changed.runtimeConfig, overrides) });
|
||||
expect(native).toMatchObject({ provider: "codex", model: "gpt-5.6-sol", codexReasoningEffort: "high" });
|
||||
|
||||
@@ -89,6 +89,7 @@ export const PAPERCLIP_PUBLIC_SCHEMA_IDS = new Set([
|
||||
"paperclip.native-execution-input.v3",
|
||||
"paperclip.native-execution-input.v4",
|
||||
"paperclip.native-execution-input.v5",
|
||||
"paperclip.native-execution-input.v7",
|
||||
"paperclip.native-finalization.v1",
|
||||
"paperclip.native-finalization.v2",
|
||||
"paperclip.native-harness-backup-stamp.v1",
|
||||
|
||||
@@ -1846,7 +1846,7 @@ export function agentRoutes(
|
||||
"OpenAI Dot is experimental and disabled on this instance.",
|
||||
{ code: "paperclip_runner_dot_disabled" },
|
||||
);
|
||||
} else if (!([undefined, "codex"].includes(asRecord(config)?.provider as string | undefined)) && experimental.enableNativeRunner !== true) {
|
||||
} else if (!([undefined, "codex"].includes(asRecord(config)?.provider as string | undefined)) && agentHarnessType("paperclip_runner", asRecord(config) ?? {}) !== "claude_local" && experimental.enableNativeRunner !== true) {
|
||||
throw unprocessable(
|
||||
"Paperclip Runner is experimental and disabled on this instance.",
|
||||
{ code: "paperclip_runner_rollout_disabled" },
|
||||
@@ -2248,12 +2248,12 @@ export function agentRoutes(
|
||||
const noInheritance = { adapterConfig, inheritedFixedClaudeOAuthBinding: false };
|
||||
if (asRecord(runtimeConfig)?.aiConnection) return noInheritance;
|
||||
if (req.actor.type !== "agent" || !req.actor.agentId) return noInheritance;
|
||||
const harness = agentHarnessType(adapterType ?? "", adapterConfig) === "codex_local" ? "codex_local" : adapterType ?? "";
|
||||
const harness = agentHarnessType(adapterType ?? "", adapterConfig);
|
||||
const credentialKeys = INHERITABLE_AGENT_CREDENTIAL_ENV_KEYS[harness];
|
||||
if (!credentialKeys) return noInheritance;
|
||||
|
||||
const parent = await svc.getById(req.actor.agentId);
|
||||
if (!parent || parent.companyId !== companyId || (harness === "codex_local" ? agentHarnessType(parent.adapterType, parent.adapterConfig) !== harness : parent.adapterType !== adapterType)) return noInheritance;
|
||||
if (!parent || parent.companyId !== companyId || agentHarnessType(parent.adapterType, parent.adapterConfig) !== harness) return noInheritance;
|
||||
// Managed parents must not pass stale legacy credential references to hires.
|
||||
if (aiRuntimeConnectionBindingSchema.safeParse(parent.runtimeConfig.aiConnection).success) return noInheritance;
|
||||
const parentEnv = asRecord(asRecord(parent.adapterConfig)?.env);
|
||||
@@ -2262,7 +2262,7 @@ export function agentRoutes(
|
||||
const existingEnv = asRecord(adapterConfig.env);
|
||||
const claudeCredentialKeys = INHERITABLE_AGENT_CREDENTIAL_ENV_KEYS.claude_local;
|
||||
const childHasClaudeCredential =
|
||||
adapterType === "claude_local" &&
|
||||
harness === "claude_local" &&
|
||||
existingEnv !== null &&
|
||||
claudeCredentialKeys.some((key) => existingEnv[key] !== undefined);
|
||||
if (childHasClaudeCredential) return noInheritance;
|
||||
|
||||
@@ -111,7 +111,8 @@ describe("server-owned Codex runner selection", () => {
|
||||
describe("Codex creation runner contract", () => {
|
||||
it("graduates only Codex and retains saved provider identity", () => {
|
||||
expect(paperclipRunnerProfileForHarness("codex_local")).toEqual({ provider: "codex" });
|
||||
for (const harness of ["claude_local", "opencode_local", "grok_local", "cursor", "pi_local", "process"]) expect(paperclipRunnerProfileForHarness(harness)).toBeUndefined();
|
||||
expect(paperclipRunnerProfileForHarness("claude_local")).toEqual({ provider: "acpx", acpxAgent: "claude" });
|
||||
for (const harness of ["opencode_local", "grok_local", "cursor", "pi_local", "process"]) expect(paperclipRunnerProfileForHarness(harness)).toBeUndefined();
|
||||
expect(agentHarnessType("paperclip_runner", { provider: "codex" })).toBe("codex_local");
|
||||
expect(agentHarnessType("paperclip_runner", { provider: "acpx", acpxAgent: "claude" })).toBe("claude_local");
|
||||
expect(agentHarnessType("paperclip_runner", { provider: "unknown" })).toBe("unknown");
|
||||
@@ -122,7 +123,7 @@ describe("Codex creation runner contract", () => {
|
||||
it("qualifies only the Codex daemon targets shipped in public server packages", () => {
|
||||
for (const { platform, architecture } of qualifiedTargets) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(true);
|
||||
for (const [platform, architecture] of [["darwin", "ia32"], ["linux", "arm64"], ["win32", "x64"], ["win32", "arm64"], ["freebsd", "x64"]]) expect(paperclipRunnerSupportsPlatform("codex_local", platform, architecture)).toBe(false);
|
||||
expect(paperclipRunnerSupportsPlatform("claude_local", "darwin", "arm64")).toBe(false);
|
||||
expect(paperclipRunnerSupportsPlatform("claude_local", "darwin", "arm64")).toBe(true);
|
||||
});
|
||||
it("keeps auto out of saved update defaults and validates request intent", () => {
|
||||
expect(createAgentSchema.parse({ name: "Codex", adapterType: "codex_local" }).runner).toBeUndefined();
|
||||
@@ -131,3 +132,47 @@ describe("Codex creation runner contract", () => {
|
||||
expect(createAgentSchema.safeParse({ name: "Codex", adapterType: "codex_local", runner: "other" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("Claude runner defaults", () => {
|
||||
const mac = { driver: "local", platform: "darwin", architecture: "arm64" };
|
||||
beforeEach(() => { state.disabled = []; state.overridden.clear(); });
|
||||
it("saves native Claude with Opus 5.5, preserving credentials and shared settings", () => {
|
||||
const config = { env: { ANTHROPIC_API_KEY: { type: "secret_ref", secretId: "key" } }, instructionsFilePath: "/workspace/AGENTS.md", timeoutSec: 180, dangerouslySkipPermissions: true };
|
||||
const result = resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: config });
|
||||
expect(result).toEqual({ adapterType: "paperclip_runner", adapterConfig: { env: config.env, instructionsFilePath: config.instructionsFilePath, timeoutSec: 180, provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", acpxPermissionMode: "approve-all", lifecycleMode: "per_turn" } });
|
||||
expect(resolveNewAgentRunner(result)).toEqual(result);
|
||||
expect(config.dangerouslySkipPermissions).toBe(true);
|
||||
});
|
||||
it("preserves explicit models and Legacy choices", () => {
|
||||
const legacy = { adapterType: "claude_local", target: mac, runner: "legacy" as const, adapterConfig: { model: "custom", chrome: true } };
|
||||
expect(resolveNewAgentRunner(legacy)).toEqual({ adapterType: legacy.adapterType, adapterConfig: legacy.adapterConfig });
|
||||
expect(resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { model: "custom" } }).adapterConfig.model).toBe("custom");
|
||||
});
|
||||
it("retains saved native Claude and allows an explicit compatible Legacy switch", () => {
|
||||
const saved = { adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "custom", acpxPermissionMode: "approve-all", lifecycleMode: "per_turn" } };
|
||||
expect(resolveNewAgentRunner(saved)).toEqual(saved);
|
||||
expect(resolveNewAgentRunner({ ...saved, runner: "legacy" })).toEqual({ adapterType: "claude_local", adapterConfig: { model: "custom", dangerouslySkipPermissions: true } });
|
||||
expect(() => resolveNewAgentRunner({ ...saved, runner: "legacy", adapterConfig: { ...saved.adapterConfig, acpxPermissionMode: "deny-all" } })).toThrow("cannot honor");
|
||||
});
|
||||
it.each(["chrome", "agentCommand", "maxTurnsPerRun", "networkScope"])("rejects unsupported %s without fallback", key => {
|
||||
expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { [key]: "configured" } })).toThrow("Legacy runner");
|
||||
});
|
||||
it.each(["low", "medium", "high", "xhigh", "max"])("translates Claude effort %s and preserves it on an explicit Legacy switch", effort => {
|
||||
const native = resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { effort } });
|
||||
expect(native.adapterConfig.modelReasoningEffort).toBe(effort);
|
||||
expect(native.adapterConfig.effort).toBeUndefined();
|
||||
expect(resolveNewAgentRunner({ ...native, runner: "legacy" }).adapterConfig).toMatchObject({ effort });
|
||||
});
|
||||
it("rejects invalid effort and unknown platform defaults", () => {
|
||||
expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac, adapterConfig: { effort: "ultra" } })).toThrow("Claude effort");
|
||||
expect(agentRunnerAvailability("claude_local", { driver: "sandbox" }).defaultRunner).toBe("legacy");
|
||||
});
|
||||
it("keeps unqualified targets and overrides legacy", () => {
|
||||
expect(agentRunnerAvailability("claude_local", target).defaultRunner).toBe("legacy");
|
||||
state.overridden.add("claude_local");
|
||||
expect(agentRunnerAvailability("claude_local", mac).defaultRunner).toBe("legacy");
|
||||
state.overridden.clear(); state.disabled = ["claude_local"];
|
||||
expect(() => resolveNewAgentRunner({ adapterType: "claude_local", target: mac })).toThrow("Claude is disabled");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agentHarnessType, isEnvironmentDriverSupportedForAdapter, paperclipRunnerSupportsPlatform, type AgentRunnerChoice, type AgentRunnerAvailability } from "@paperclipai/shared";
|
||||
import { normalizeLegacyRunnerProvider, PAPERCLIP_RUNNER_DEFAULT_MODELS } from "@paperclipai/adapter-utils";
|
||||
import { resolveClaudeModel } from "@paperclipai/adapter-claude-local";
|
||||
import { findActiveServerAdapter, hasActiveAdapterOverride, waitForExternalAdapters } from "../adapters/registry.js";
|
||||
import { getDisabledAdapterTypes } from "./adapter-plugin-store.js";
|
||||
import { PaperclipRunnerProviderProfileError, resolvePaperclipRunnerNativeProviderInput } from "./native-runtime/provider-profile.js";
|
||||
import { claudeRunnerUnsupportedSettings, PaperclipRunnerProviderProfileError, resolvePaperclipRunnerNativeProviderInput } from "./native-runtime/provider-profile.js";
|
||||
import { forbidden, unprocessable } from "../errors.js";
|
||||
import { runSshCommand } from "@paperclipai/adapter-utils/ssh";
|
||||
import { resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js";
|
||||
@@ -16,14 +17,20 @@ export interface AgentRunnerSelection {
|
||||
target?: RunnerTarget;
|
||||
}
|
||||
|
||||
export function agentRunnerAvailability(harness: string, target: RunnerTarget = { driver: "local", platform: process.platform, architecture: process.arch }): AgentRunnerAvailability {
|
||||
function runnerAdaptersAvailable(harness: string, driver: string): boolean {
|
||||
const disabled = new Set(getDisabledAdapterTypes());
|
||||
const supported = harness === "codex_local"
|
||||
return ["codex_local", "claude_local"].includes(harness)
|
||||
&& !disabled.has(harness) && !disabled.has("paperclip_runner")
|
||||
&& !!findActiveServerAdapter(harness) && !!findActiveServerAdapter("paperclip_runner")
|
||||
&& !hasActiveAdapterOverride(harness) && !hasActiveAdapterOverride("paperclip_runner")
|
||||
&& isEnvironmentDriverSupportedForAdapter("paperclip_runner", target.driver)
|
||||
&& (!target.platform || paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? ""));
|
||||
&& isEnvironmentDriverSupportedForAdapter("paperclip_runner", driver);
|
||||
}
|
||||
|
||||
export function agentRunnerAvailability(harness: string, target: RunnerTarget = { driver: "local", platform: process.platform, architecture: process.arch }): AgentRunnerAvailability {
|
||||
const supported = runnerAdaptersAvailable(harness, target.driver)
|
||||
&& (harness === "claude_local"
|
||||
? !!target.platform && paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? "")
|
||||
: !target.platform || paperclipRunnerSupportsPlatform(harness, target.platform, target.architecture ?? ""));
|
||||
return { supportedRunners: supported ? ["paperclip", "legacy"] : ["legacy"], defaultRunner: supported ? "paperclip" : "legacy" };
|
||||
}
|
||||
|
||||
@@ -31,19 +38,41 @@ function incompatible(fields: string[]): never {
|
||||
throw unprocessable(`Paperclip Runner cannot honor these settings: ${fields.join(", ")}. Remove them or select Legacy runner in Advanced.`, { code: "agent_runner_config_incompatible", fields });
|
||||
}
|
||||
|
||||
function resolveClaudeRunnerConfig(config: Record<string, unknown>): Record<string, unknown> {
|
||||
const unsupported = claudeRunnerUnsupportedSettings(config);
|
||||
const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort;
|
||||
if (config.model !== undefined && typeof config.model !== "string") unsupported.push("model");
|
||||
if (unsupported.length) incompatible(unsupported);
|
||||
const translated = { ...config };
|
||||
for (const key of ["engine", "command", "mode", "nonInteractivePermissions", "dangerouslySkipPermissions", "chrome", "maxTurnsPerRun", "effort", "reasoningEffort"]) delete translated[key];
|
||||
const env = config.env && typeof config.env === "object" ? config.env as Record<string, unknown> : {};
|
||||
const model = resolveClaudeModel(config.model, env);
|
||||
if (!model) incompatible(["model"]);
|
||||
return { ...translated, ...(effort === undefined || effort === "" ? {} : { modelReasoningEffort: effort }), provider: "acpx", acpxAgent: "claude", model, acpxPermissionMode: config.acpxPermissionMode ?? "approve-all", lifecycleMode: config.lifecycleMode ?? "per_turn" };
|
||||
}
|
||||
|
||||
/** Idempotent translation. Missing dependencies or credentials never select another runner. */
|
||||
export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterType: string; adapterConfig: Record<string, unknown> } {
|
||||
const adapterType = input.adapterType ?? "process";
|
||||
let config = { ...input.adapterConfig };
|
||||
const harness = agentHarnessType(adapterType, config);
|
||||
const native = adapterType === "paperclip_runner";
|
||||
if (adapterType === "codex_local" && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable("Codex is disabled on this instance. Enable its adapter or choose another harness.", { code: "agent_runner_unavailable" });
|
||||
if (["codex_local", "claude_local"].includes(adapterType) && getDisabledAdapterTypes().includes(adapterType)) throw unprocessable(`${adapterType === "claude_local" ? "Claude" : "Codex"} is disabled on this instance. Enable its adapter or choose another harness.`, { code: "agent_runner_unavailable" });
|
||||
// Explicit saved native profiles (including historical provider omission) retain execution.
|
||||
if (native && (input.runner === undefined || input.runner === "paperclip" || (harness !== "codex_local" && input.runner !== "legacy"))) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
|
||||
const availability = agentRunnerAvailability(harness, input.target);
|
||||
const choice = input.runner ?? "auto";
|
||||
const useNative = choice === "paperclip" || (choice === "auto" && availability.defaultRunner === "paperclip");
|
||||
if (!useNative) {
|
||||
if (harness === "claude_local") {
|
||||
if (!native) return { adapterType, adapterConfig: config };
|
||||
if (config.acpxPermissionMode !== undefined && config.acpxPermissionMode !== "approve-all") incompatible(["acpxPermissionMode"]);
|
||||
if (config.lifecycleMode !== undefined && config.lifecycleMode !== "per_turn") incompatible(["lifecycleMode"]);
|
||||
const effort = config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort;
|
||||
for (const key of ["provider", "acpxAgent", "acpxPermissionMode", "lifecycleMode", "idleTimeoutMs", "modelReasoningEffort", "reasoningEffort"]) delete config[key];
|
||||
if (effort !== undefined) config.effort = effort;
|
||||
return { adapterType: "claude_local", adapterConfig: { ...config, dangerouslySkipPermissions: config.dangerouslySkipPermissions ?? true } };
|
||||
}
|
||||
if (!native && harness !== "codex_local") return { adapterType, adapterConfig: config };
|
||||
if (!native && !["codexPermissionMode", "lifecycleMode", "idleTimeoutMs"].some(key => config[key] !== undefined)) return { adapterType, adapterConfig: config };
|
||||
if (harness !== "codex_local") throw unprocessable("This native harness does not have a runner override in this release.", { code: "agent_runner_unavailable" });
|
||||
@@ -55,6 +84,15 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp
|
||||
if (native && harness !== "codex_local" && choice === "paperclip") return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
|
||||
if (availability.defaultRunner !== "paperclip") throw unprocessable("Paperclip Runner is unavailable for this harness and environment. Select Legacy runner in Advanced.", { code: "agent_runner_unavailable" });
|
||||
if (native) return { adapterType, adapterConfig: normalizeLegacyRunnerProvider(config) };
|
||||
if (harness === "claude_local") {
|
||||
config = resolveClaudeRunnerConfig(config);
|
||||
try { resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: config }); }
|
||||
catch (error) {
|
||||
if (error instanceof PaperclipRunnerProviderProfileError) throw unprocessable(error.message, { code: error.code });
|
||||
throw error;
|
||||
}
|
||||
return { adapterType: "paperclip_runner", adapterConfig: config };
|
||||
}
|
||||
const unsupported = ["agentCommand", "stateDir", "warmHandleIdleMs", "extraArgs", "args", "search", "fastMode", "variant", "filesystemScope", "filesystemExtraPaths", "filesystemSandboxCommand", "networkScope", "networkAllowlist", "permissionMode", "maxTurns", "disableWebSearch", "outputInactivityTimeoutMs", "terminalResultCleanupGraceMs"]
|
||||
.filter(key => config[key] !== undefined && config[key] !== false && config[key] !== "" && config[key] !== 0 && !(Array.isArray(config[key]) && config[key].length === 0));
|
||||
if (config.command && config.command !== "codex") unsupported.push("command");
|
||||
@@ -78,7 +116,7 @@ export function resolveNewAgentRunner(input: AgentRunnerSelection): { adapterTyp
|
||||
|
||||
/** Use the execution environment selected by dispatch, without starting a provider. */
|
||||
export async function resolveNewAgentRunnerForCompany(db: Db, companyId: string, input: AgentRunnerSelection & { defaultEnvironmentId?: string | null }) {
|
||||
if (agentHarnessType(input.adapterType ?? "process", input.adapterConfig) !== "codex_local"
|
||||
if (!["codex_local", "claude_local"].includes(agentHarnessType(input.adapterType ?? "process", input.adapterConfig))
|
||||
|| input.runner === "legacy" || (input.adapterType === "paperclip_runner" && (input.runner === undefined || input.runner === "paperclip"))) return resolveNewAgentRunner(input);
|
||||
const target = await resolveAgentRunnerTargetForCompany(db, companyId, input.defaultEnvironmentId);
|
||||
return resolveNewAgentRunner({ ...input, target });
|
||||
@@ -111,7 +149,7 @@ export async function resolveAgentRunnerTargetForCompany(db: Db, companyId: stri
|
||||
const driver = environment?.driver ?? "local";
|
||||
let target: RunnerTarget = driver === "local" ? { driver, platform: process.platform, architecture: process.arch }
|
||||
: { driver }; // Sandbox platform/artifact identity is verified by selected-runtime setup; do not guess an image's architecture.
|
||||
if (driver === "ssh" && environment && agentRunnerAvailability("codex_local", target).defaultRunner === "paperclip") {
|
||||
if (driver === "ssh" && environment && ["codex_local", "claude_local"].some(harness => runnerAdaptersAvailable(harness, driver))) {
|
||||
const parsed = await resolveEnvironmentDriverConfigForRuntime(db, companyId, environment);
|
||||
if (parsed.driver !== "ssh") throw unprocessable("The selected SSH environment is unavailable.");
|
||||
let output: string;
|
||||
|
||||
@@ -55,8 +55,8 @@ export function poolMemberRuntimeConfig(member: AiConnectionPoolMember, adapterT
|
||||
const efforts = provider === "openai" ? codexLocalReasoningEffortsForModel(model)
|
||||
: provider === "anthropic" ? claudeLocalReasoningEffortsForModel(model)
|
||||
: provider === "xai" ? grokLocalReasoningEffortsForModel(model) : [];
|
||||
// ACPX currently has no qualified effort override; keep that host limitation explicit.
|
||||
const effortKey = adapterType === "paperclip_runner" ? profile.provider === "codex" ? "modelReasoningEffort" : null
|
||||
// Claude and Codex carry effort through native admission; other ACPX profiles keep their existing limit.
|
||||
const effortKey = adapterType === "paperclip_runner" ? profile.provider === "codex" || (profile.provider === "acpx" && profile.acpxAgent === "claude") ? "modelReasoningEffort" : null
|
||||
: provider === "anthropic" ? "effort" : provider === "xai" ? "reasoningEffort" : provider === "openai" ? "modelReasoningEffort" : "variant";
|
||||
const requested = overrides.modelReasoningEffort ?? overrides.reasoningEffort ?? overrides.effort ?? overrides.variant;
|
||||
const effort = typeof requested === "string" && efforts.includes(requested) && effortKey ? requested
|
||||
|
||||
@@ -6,6 +6,12 @@ const receipt: AdapterUsageCheckpoint = { complete: true, usageBasis: "per_run",
|
||||
billingType: "metered_api", model: "claude-sonnet-5", costUsd: null, costStatus: "unpriced",
|
||||
usage: { inputTokens: 22462, cachedInputTokens: 208218, cacheWriteTokens: 22450, outputTokens: 2450 } };
|
||||
describe("complete Anthropic receipt pricing", () => {
|
||||
it("prices Opus 5.5 cache writes at the one-hour upper rate", () => {
|
||||
const priced = priceAnthropicReceipt({ ...receipt, model: "claude-opus-5-5" });
|
||||
expect(priced.costUsdExact).toBe("0.270291600");
|
||||
expect(priced.pricingProvenance).toMatchObject({ version: "anthropic-standard-2026-10-12", inputCentsPerMillion: "400", cachedInputCentsPerMillion: "20", cacheWriteCentsPerMillion: "800", outputCentsPerMillion: "2000" });
|
||||
expect(priced.costStatus).toBe("estimated");
|
||||
});
|
||||
it("prices complete counters conservatively and labels their provenance as an estimate", () => {
|
||||
const priced = priceAnthropicReceipt(receipt);
|
||||
expect(priced.costUsdExact).toBe("0.155967600");
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
import { centsToUsd, unitsToCents, usdToUnits } from "@paperclipai/shared";
|
||||
import type { AdapterUsageCheckpoint } from "@paperclipai/adapter-utils";
|
||||
|
||||
// Direct Anthropic API list prices, verified 2026-10-07:
|
||||
// Direct Anthropic API list prices, verified per model below:
|
||||
// https://platform.claude.com/docs/en/about-claude/pricing
|
||||
// Keep this bounded to the qualified model; aliases and other billers stay unknown.
|
||||
const RATES = ["2", "0.2", "4", "10"] as const;
|
||||
// Keep this bounded to exact qualified models; aliases and other billers stay unknown.
|
||||
const RATE_CARDS: Record<string, { rates: readonly string[]; version: string }> = {
|
||||
"claude-sonnet-5": { rates: ["2", "0.2", "4", "10"], version: "anthropic-standard-2026-10-07" },
|
||||
"claude-opus-5-5": { rates: ["4", "0.2", "8", "20"], version: "anthropic-standard-2026-10-12" },
|
||||
};
|
||||
|
||||
/** A complete token receipt can receive an explicit estimate, never an invoice
|
||||
* claim. ACP aggregates cache writes without TTL; conservatively price them at
|
||||
* the documented one-hour rate rather than silently assume five-minute writes. */
|
||||
export function priceAnthropicReceipt(receipt: AdapterUsageCheckpoint): AdapterUsageCheckpoint {
|
||||
const card = receipt.model ? RATE_CARDS[receipt.model] : undefined;
|
||||
if (!receipt.complete || !receipt.usage || receipt.usageBasis !== "per_run"
|
||||
|| receipt.provider !== "anthropic" || receipt.biller !== "anthropic"
|
||||
|| !["api", "metered_api"].includes(receipt.billingType ?? "")
|
||||
|| receipt.model !== "claude-sonnet-5" || receipt.usageByModel?.length
|
||||
|| !card || receipt.usageByModel?.length
|
||||
|| receipt.costUsd != null || receipt.costUsdExact != null || receipt.cacheAdjustedCostUsd != null
|
||||
|| (receipt.pricingContext?.serviceTier && !["standard", "default"].includes(receipt.pricingContext.serviceTier))) return receipt;
|
||||
const usage = receipt.usage;
|
||||
if (usage.cacheWriteTokens === undefined || usage.cachedInputTokens === undefined) return receipt;
|
||||
const counts = [usage.inputTokens - usage.cacheWriteTokens, usage.cachedInputTokens, usage.cacheWriteTokens, usage.outputTokens];
|
||||
if (counts.some(count => !Number.isSafeInteger(count) || count < 0)) return receipt;
|
||||
const numerator = counts.reduce((total, count, index) => total + BigInt(count) * usdToUnits(RATES[index]), 0n);
|
||||
const numerator = counts.reduce((total, count, index) => total + BigInt(count) * usdToUnits(card.rates[index]), 0n);
|
||||
return { ...receipt, costUsdExact: centsToUsd(unitsToCents((numerator + 500_000n) / 1_000_000n)), costStatus: "estimated",
|
||||
pricingProvenance: {
|
||||
source: "rate_card", version: "anthropic-standard-2026-10-07",
|
||||
source: "rate_card", version: card.version,
|
||||
evidence: "https://platform.claude.com/docs/en/about-claude/pricing; standard global API pricing assumed; cache-write TTL unavailable, one-hour upper rate used; not an invoice",
|
||||
inputCentsPerMillion: "200", cachedInputCentsPerMillion: "20", cacheWriteCentsPerMillion: "400", outputCentsPerMillion: "1000", serviceTier: "standard",
|
||||
inputCentsPerMillion: (usdToUnits(card.rates[0]) / 10_000_000n).toString(),
|
||||
cachedInputCentsPerMillion: (usdToUnits(card.rates[1]) / 10_000_000n).toString(),
|
||||
cacheWriteCentsPerMillion: (usdToUnits(card.rates[2]) / 10_000_000n).toString(),
|
||||
outputCentsPerMillion: (usdToUnits(card.rates[3]) / 10_000_000n).toString(), serviceTier: "standard",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1762,11 +1762,12 @@ export function builtInAgentService(db: Db) {
|
||||
const adapterType = resolvedInput.adapterType ?? existing.adapterType;
|
||||
const adapterConfig = resolvedInput.adapterConfig ?? existing.adapterConfig;
|
||||
assertAdapterAllowed(definition, adapterType, adapterConfig);
|
||||
const sameCodexHarness = agentHarnessType(adapterType, adapterConfig) === "codex_local"
|
||||
&& agentHarnessType(existing.adapterType, existing.adapterConfig) === "codex_local";
|
||||
const runner = resolvedInput.runner ?? (sameCodexHarness && existing.metadata?.paperclipBuiltInSetupRequired !== true
|
||||
const selectedHarness = agentHarnessType(adapterType, adapterConfig);
|
||||
const sameQualifiedHarness = ["codex_local", "claude_local"].includes(selectedHarness)
|
||||
&& agentHarnessType(existing.adapterType, existing.adapterConfig) === selectedHarness;
|
||||
const runner = resolvedInput.runner ?? (sameQualifiedHarness && existing.metadata?.paperclipBuiltInSetupRequired !== true
|
||||
? agentRunner(existing.adapterType) : undefined);
|
||||
const retainedNative = sameCodexHarness && runner === "paperclip" && existing.adapterType === "paperclip_runner";
|
||||
const retainedNative = sameQualifiedHarness && runner === "paperclip" && existing.adapterType === "paperclip_runner";
|
||||
const resolvedAdapterConfig = { ...adapterConfig };
|
||||
if (retainedNative) {
|
||||
for (const field of ["provider", "acpxAgent", "acpxMode"]) {
|
||||
|
||||
@@ -5294,7 +5294,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
"OpenAI Dot is experimental and disabled on this instance.",
|
||||
{ code: "paperclip_runner_dot_disabled" },
|
||||
);
|
||||
} else if (config.provider !== "codex" && config.provider !== undefined && experimental.enableNativeRunner !== true) {
|
||||
} else if (config.provider !== "codex" && config.provider !== undefined && agentHarnessType("paperclip_runner", config) !== "claude_local" && experimental.enableNativeRunner !== true) {
|
||||
throw unprocessable(
|
||||
"Paperclip Runner is experimental and disabled on this instance.",
|
||||
{ code: "paperclip_runner_rollout_disabled" },
|
||||
@@ -5642,7 +5642,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
}
|
||||
// The picker sends harness identity for config edits. Keep a saved
|
||||
// native profile, including on a source-built unqualified platform.
|
||||
const retainedNative = runner === "paperclip" && selectedHarness === "codex_local"
|
||||
const retainedNative = runner === "paperclip" && ["codex_local", "claude_local"].includes(selectedHarness)
|
||||
? [manifestAgent, existingImportAgent].find((agent) => agent?.adapterType === "paperclip_runner"
|
||||
&& agentHarnessType(agent.adapterType, agent.adapterConfig) === selectedHarness)
|
||||
: undefined;
|
||||
|
||||
@@ -91,6 +91,16 @@ describe("Paperclip Runner native provider configuration", () => {
|
||||
.toMatchObject({ provider: "codex", model: "gpt-6-astra", modelReasoningEffort: "high" });
|
||||
});
|
||||
|
||||
it("carries Claude task effort while retaining provider and permissions", () => {
|
||||
const base = { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", modelReasoningEffort: "medium", acpxPermissionMode: "deny-all" };
|
||||
const projected = projectPaperclipRunnerTaskConfig("acpx_runtime", base, { effort: "xhigh", provider: "codex", acpxAgent: "pi", acpxPermissionMode: "approve-all" });
|
||||
expect(projected).toEqual({ ...base, modelReasoningEffort: "xhigh" });
|
||||
expect(resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: projected })).toMatchObject({ provider: "acpx", acpxAgent: "claude", claudeEffort: "xhigh", acpxPermissionMode: "deny-all" });
|
||||
const changedModel = projectPaperclipRunnerTaskConfig("acpx_runtime", base, { model: "claude-sonnet-5" });
|
||||
expect(changedModel).not.toHaveProperty("modelReasoningEffort");
|
||||
expect(() => resolvePaperclipRunnerNativeProviderInput({ backend: "acpx_runtime", adapterConfig: { ...base, chrome: true } })).toThrow("Legacy runner");
|
||||
});
|
||||
|
||||
it("uses an OpenCode task model without changing the Runner provider", () => {
|
||||
const projected = projectPaperclipRunnerTaskConfig(
|
||||
"opencode_server",
|
||||
|
||||
@@ -696,6 +696,7 @@ export async function selectHeartbeatRuntime(db: Db, input: HeartbeatRuntimeSele
|
||||
});
|
||||
getNativeFreshSessionHandoff = nativeReviewRequest ? undefined : getFreshSessionHandoff;
|
||||
const nativeProviderConfig = nativeRuntimeResolution.profile.backend === "codex_app_server"
|
||||
|| (nativeRuntimeResolution.profile.backend === "acpx_runtime" && nativeRuntimeResolution.profile.provider === "acpx" && nativeRuntimeResolution.profile.acpxAgent === "claude")
|
||||
|| nativeRuntimeResolution.profile.backend === "opencode_server"
|
||||
? projectPaperclipRunnerTaskConfig(
|
||||
nativeRuntimeResolution.profile.backend,
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("host ACPX qualification admission", () => {
|
||||
}
|
||||
});
|
||||
it("preserves product defaults separately from qualification examples", () => {
|
||||
for (const [acpxAgent, model] of [["claude", "claude-sonnet-5"], ["grok", "grok-4.7"]]) {
|
||||
for (const [acpxAgent, model] of [["claude", "claude-opus-5-5"], ["grok", "grok-4.7"]]) {
|
||||
expect(resolvePaperclipRunnerProviderProfile({ provider: "acpx", acpxAgent }))
|
||||
.toMatchObject({ acpxAgent, model });
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
NativeCodexApprovalPolicy,
|
||||
NativeExecutionInputV5,
|
||||
NativeExecutionInputV6,
|
||||
NativeExecutionInputV7,
|
||||
DotBindingSnapshot,
|
||||
NativeCompletionSource,
|
||||
NativeInteractionResponseEnvelope,
|
||||
@@ -80,6 +81,7 @@ export interface BuildNativeExecutionInput {
|
||||
acpxPermissionMode?: NativeAcpxPermissionMode;
|
||||
acpxSessionMode?: "agent" | "plan" | "ask";
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
model?: string | null;
|
||||
managedProfile?: Extract<
|
||||
NativeExecutionInputV5["provider"],
|
||||
@@ -109,9 +111,9 @@ export interface BuildNativeExecutionInput {
|
||||
runtimeContext: NativeRuntimeContextSnapshot;
|
||||
}
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider: "openai_dot"; dotBinding: DotBindingSnapshot }): NativeExecutionInputV6;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider?: Exclude<BuildNativeExecutionInput["provider"], "openai_dot"> }): NativeExecutionInputV5;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 {
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider?: Exclude<BuildNativeExecutionInput["provider"], "openai_dot"> }): NativeExecutionInputV5 | NativeExecutionInputV7;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 {
|
||||
if (input.issue.workMode !== "standard" && input.issue.workMode !== "planning" && input.issue.workMode !== "ask") {
|
||||
throw new Error("native_execution_input_invalid: issue work mode must be standard, planning, or ask");
|
||||
}
|
||||
@@ -211,7 +213,7 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
|
||||
? verifiedCompletionSources(input.turnContext, input.completionContract.sources ?? [])
|
||||
: [];
|
||||
const prepared = {
|
||||
schema: "paperclip.native-execution-input.v5",
|
||||
schema: input.claudeEffort === undefined ? "paperclip.native-execution-input.v5" : "paperclip.native-execution-input.v7",
|
||||
...((input.initialCommunicationGuidance || input.freshSessionHandoff) ? {
|
||||
initialCommunicationGuidance: [input.initialCommunicationGuidance, input.freshSessionHandoff].filter(Boolean).join("\n\n"),
|
||||
} : {}),
|
||||
@@ -290,6 +292,7 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
|
||||
permissionMode: input.acpxPermissionMode ?? "approve-all",
|
||||
...(mode === undefined ? {} : { mode }),
|
||||
...(piThinkingLevel === undefined ? {} : { piThinkingLevel }),
|
||||
...(input.claudeEffort === undefined ? {} : { claudeEffort: input.claudeEffort }),
|
||||
profile: {
|
||||
driverKind: acpxProfile!.driverKind,
|
||||
protocolVersion: acpxProfile!.protocolVersion,
|
||||
@@ -334,7 +337,7 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
|
||||
schema: "paperclip.native-execution-input.v6", provider: { kind: "openai_dot", model: null, binding: input.dotBinding },
|
||||
workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null },
|
||||
session: { ...prepared.session, driverKind: "openai_dot_mcp", lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }
|
||||
} : prepared) as NativeExecutionInputV5 | NativeExecutionInputV6;
|
||||
} : prepared) as NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5625,6 +5625,7 @@ export function providerSessionIdentityFromDurableProviderState(input: {
|
||||
identity.permissionMode !== input.execution.provider.permissionMode ||
|
||||
!acpxRecoveryModeMatches(input.execution.provider, descriptor.mode, identity.mode) ||
|
||||
!acpxRecoveryPiThinkingMatches(input.execution.provider, descriptor.piThinkingLevel, identity.piThinkingLevel) ||
|
||||
!acpxRecoveryClaudeEffortMatches(input.execution.provider, descriptor.claudeEffort, identity.claudeEffort) ||
|
||||
!["approve-all", "approve-paperclip", "approve-reads", "deny-all"].includes(
|
||||
String(identity.permissionMode),
|
||||
) ||
|
||||
@@ -5732,6 +5733,13 @@ function acpxRecoveryModeMatches(
|
||||
&& observedModes.every(mode => mode === expected);
|
||||
}
|
||||
|
||||
function acpxRecoveryClaudeEffortMatches(provider: unknown, projected: unknown, observed: unknown): boolean {
|
||||
const config = record(provider);
|
||||
const expected = config.claudeEffort;
|
||||
if (config.kind !== "acpx" || config.agent !== "claude") return expected === undefined && projected === undefined && observed === undefined;
|
||||
return expected === projected && expected === observed && (expected === undefined || ["low", "medium", "high", "xhigh", "max"].includes(String(expected)));
|
||||
}
|
||||
|
||||
function acpxRecoveryPiThinkingMatches(provider: NativeExecutionInput["provider"], ...observed: unknown[]): boolean {
|
||||
const expected = record(provider).piThinkingLevel;
|
||||
if (provider.kind === "acpx" && provider.agent === "pi") return ["off", "low", "high", "max"].includes(String(expected)) && observed.every(value => value === expected);
|
||||
@@ -5749,6 +5757,7 @@ export function providerSessionIdentityTransitionIsAllowed(input: {
|
||||
record(record(input.current).providerSessionIdentity).mode,
|
||||
)) return false;
|
||||
if (!acpxRecoveryPiThinkingMatches(input.execution.provider, record(record(input.previous).providerSessionIdentity).piThinkingLevel, record(record(input.current).providerSessionIdentity).piThinkingLevel)) return false;
|
||||
if (!acpxRecoveryClaudeEffortMatches(input.execution.provider, record(record(input.previous).providerSessionIdentity).claudeEffort, record(record(input.current).providerSessionIdentity).claudeEffort)) return false;
|
||||
if (canonicalJson(input.previous) === canonicalJson(input.current)) {
|
||||
return true;
|
||||
}
|
||||
@@ -12872,9 +12881,11 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
acpxPermissionMode: input.execution.provider.permissionMode,
|
||||
acpxMode: input.execution.provider.mode,
|
||||
piThinkingLevel: input.execution.provider.piThinkingLevel,
|
||||
claudeEffort: "claudeEffort" in input.execution.provider ? input.execution.provider.claudeEffort : undefined,
|
||||
acpxPermissionModePinned:
|
||||
input.execution.schema === "paperclip.native-execution-input.v4" ||
|
||||
input.execution.schema === "paperclip.native-execution-input.v5",
|
||||
input.execution.schema === "paperclip.native-execution-input.v5" ||
|
||||
input.execution.schema === "paperclip.native-execution-input.v7",
|
||||
acpxRuntimeDirectory: remoteRunnerFilesystemRoot
|
||||
? posix.join(remoteRunnerFilesystemRoot, "acpx")
|
||||
: resolve(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolveQualifiedAcpxProfile } from "../../vendor/paperclip-runner/index.js";
|
||||
import { resolveClaudeEffort, resolveQualifiedAcpxProfile } from "../../vendor/paperclip-runner/index.js";
|
||||
import {
|
||||
isPaperclipRunnerProvider,
|
||||
PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES,
|
||||
@@ -26,7 +26,7 @@ export const CLAUDE_MANAGED_BETA_VERSION = "managed-agents-2026-04-01" as const;
|
||||
|
||||
export const DEFAULT_ACPX_RUNNER_MODELS = {
|
||||
grok: "grok-4.7",
|
||||
claude: "claude-sonnet-5",
|
||||
claude: "claude-opus-5-5",
|
||||
// These profiles require explicit configuration. Admission belongs to the runner.
|
||||
codex: null,
|
||||
cursor: null,
|
||||
@@ -130,6 +130,7 @@ export type PaperclipRunnerNativeProviderInput =
|
||||
acpxPermissionMode: "approve-all" | "approve-paperclip" | "approve-reads" | "deny-all";
|
||||
acpxSessionMode?: "agent" | "plan" | "ask";
|
||||
piThinkingLevel?: "off" | "low" | "high" | "max";
|
||||
claudeEffort?: "low" | "medium" | "high" | "xhigh" | "max";
|
||||
};
|
||||
|
||||
export class PaperclipRunnerProviderProfileError extends Error {
|
||||
@@ -165,9 +166,9 @@ export function validatePaperclipRunnerDotConfig(config: Record<string, unknown>
|
||||
return bindingId;
|
||||
}
|
||||
|
||||
/** A task may change a local Runner model (and Codex effort), but not provider identity. */
|
||||
/** A task may change a local Runner model and supported effort, but not provider identity. */
|
||||
export function projectPaperclipRunnerTaskConfig(
|
||||
backend: "codex_app_server" | "opencode_server",
|
||||
backend: "codex_app_server" | "opencode_server" | "acpx_runtime",
|
||||
agentConfig: unknown,
|
||||
taskOverrides: unknown,
|
||||
managedModel?: string,
|
||||
@@ -178,10 +179,11 @@ export function projectPaperclipRunnerTaskConfig(
|
||||
// The server resolves a connection's model namespace after task overrides.
|
||||
// Carry that model into durable input without allowing routing to change the harness.
|
||||
const model = optionalString(managedModel) ?? optionalString(task.model);
|
||||
const effortKey = backend === "codex_app_server"
|
||||
const supportsEffort = backend === "codex_app_server" || (backend === "acpx_runtime" && base.acpxAgent === "claude");
|
||||
const effortKey = supportsEffort
|
||||
? ["modelReasoningEffort", "reasoningEffort", "effort"].find((key) => key in task)
|
||||
: undefined;
|
||||
if (backend === "codex_app_server" && ((model !== null && model !== optionalString(base.model)) || effortKey !== undefined)) {
|
||||
if (supportsEffort && ((model !== null && model !== optionalString(base.model)) || effortKey !== undefined)) {
|
||||
delete config.modelReasoningEffort;
|
||||
delete config.reasoningEffort;
|
||||
delete config.effort;
|
||||
@@ -352,6 +354,17 @@ export function assertAgentCoreProfileRecoveryBinding(input: {
|
||||
}
|
||||
}
|
||||
|
||||
export function claudeRunnerUnsupportedSettings(config: Record<string, unknown>): string[] {
|
||||
const unsupported = ["chrome", "agentCommand", "stateDir", "warmHandleIdleMs", "extraArgs", "args", "filesystemScope", "filesystemExtraPaths", "filesystemSandboxCommand", "networkScope", "networkAllowlist", "maxTurnsPerRun"]
|
||||
.filter(key => config[key] !== undefined && config[key] !== false && config[key] !== "" && config[key] !== 0 && !(Array.isArray(config[key]) && config[key].length === 0));
|
||||
if (config.command && config.command !== "claude") unsupported.push("command");
|
||||
if (config.mode && config.mode !== "persistent") unsupported.push("mode");
|
||||
if (config.engine && !["auto", "cli", "acp"].includes(String(config.engine))) unsupported.push("engine");
|
||||
if (config.nonInteractivePermissions && config.nonInteractivePermissions !== "deny") unsupported.push("nonInteractivePermissions");
|
||||
if (config.dangerouslySkipPermissions === false) unsupported.push("dangerouslySkipPermissions");
|
||||
return unsupported;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the immutable provider identity used for a fresh Paperclip Runner
|
||||
* selection. The persisted adapterConfig is the authority; runtimeConfig is
|
||||
@@ -374,6 +387,11 @@ export function resolvePaperclipRunnerProviderProfile(
|
||||
const bindingId = validatePaperclipRunnerDotConfig(config)!;
|
||||
return { provider: "openai_dot", backend: "openai_dot_mcp", model: null, dotBindingId: bindingId };
|
||||
}
|
||||
if (candidate === "acpx" && config.acpxAgent === "claude") {
|
||||
const unsupported = claudeRunnerUnsupportedSettings(config);
|
||||
if (unsupported.length) throw new PaperclipRunnerProviderProfileError("agent_runner_config_incompatible",
|
||||
`Paperclip Runner cannot honor these settings: ${unsupported.join(", ")}. Remove them or select Legacy runner in Advanced.`);
|
||||
}
|
||||
assertPermissionMode(candidate, config);
|
||||
try {
|
||||
resolvePaperclipRunnerCursorMode(candidate, config.acpxAgent, config.acpxSessionMode);
|
||||
@@ -383,6 +401,9 @@ export function resolvePaperclipRunnerProviderProfile(
|
||||
error instanceof Error ? error.message : "Invalid Cursor session mode",
|
||||
);
|
||||
}
|
||||
try { if (candidate === "acpx" && config.acpxAgent === "claude") resolveClaudeEffort("claude", config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort); } catch (error) {
|
||||
throw new PaperclipRunnerProviderProfileError("paperclip_runner_claude_effort_invalid", error instanceof Error ? error.message : "Invalid Claude effort");
|
||||
}
|
||||
try { resolvePaperclipRunnerPiThinkingLevel(candidate, config.acpxAgent, config.piThinkingLevel); } catch (error) {
|
||||
throw new PaperclipRunnerProviderProfileError("paperclip_runner_pi_thinking_invalid", error instanceof Error ? error.message : "Invalid Pi thinking level");
|
||||
}
|
||||
@@ -573,6 +594,7 @@ export function resolvePaperclipRunnerNativeProviderInput(input: {
|
||||
provider: "acpx",
|
||||
model: profile.model,
|
||||
acpxAgent: profile.acpxAgent,
|
||||
...(profile.acpxAgent === "claude" ? { claudeEffort: resolveClaudeEffort("claude", config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort) } : {}),
|
||||
...(profile.acpxAgent === "pi" ? { piThinkingLevel: resolvePaperclipRunnerPiThinkingLevel("acpx", "pi", config.piThinkingLevel) } : {}),
|
||||
...(profile.acpxAgent === "cursor" ? {
|
||||
acpxSessionMode: resolvePaperclipRunnerCursorMode("acpx", "cursor", config.acpxSessionMode),
|
||||
|
||||
@@ -50,6 +50,11 @@ describe("resolveNativeRuntimeMode", () => {
|
||||
})).toMatchObject({ kind: "native", profile: { backend: "codex_app_server" } });
|
||||
});
|
||||
|
||||
it("admits native Claude without enabling other ACPX providers", () => {
|
||||
expect(resolveNativeRuntimeMode({ ...eligible, enabled: false, adapterConfig: { provider: "acpx", acpxAgent: "claude" } })).toMatchObject({ kind: "native", profile: { backend: "acpx_runtime" } });
|
||||
expect(() => resolveNativeRuntimeMode({ ...eligible, enabled: false, adapterConfig: { provider: "acpx", acpxAgent: "grok" } })).toThrow(expect.objectContaining({ code: "paperclip_runner_rollout_disabled" }));
|
||||
});
|
||||
|
||||
it("still rejects experimental providers while the rollout flag is disabled", () => {
|
||||
expect(() => resolveNativeRuntimeMode({
|
||||
...eligible,
|
||||
|
||||
@@ -130,7 +130,7 @@ export function resolveNativeRuntimeMode(input: {
|
||||
// enabling Dot does not opt in any of those providers.
|
||||
if (runnerProfile.provider === "openai_dot") {
|
||||
if (input.dotEnabled !== true) throw ineligible("paperclip_runner_dot_disabled", "Enable OpenAI Dot and Assistant connections (MCP) in experimental settings before assigning new work.");
|
||||
} else if (runnerProfile.provider !== "codex" && !input.enabled) {
|
||||
} else if (runnerProfile.provider !== "codex" && !(runnerProfile.provider === "acpx" && runnerProfile.acpxAgent === "claude") && !input.enabled) {
|
||||
throw ineligible(
|
||||
"paperclip_runner_rollout_disabled",
|
||||
"Paperclip Runner is experimental and disabled on this instance.",
|
||||
|
||||
+2
@@ -36,6 +36,7 @@ export type {
|
||||
NativeExecutionInputV4,
|
||||
NativeExecutionInputV5,
|
||||
NativeExecutionInputV6,
|
||||
NativeExecutionInputV7,
|
||||
DotBindingSnapshot,
|
||||
ExternalProviderPort,
|
||||
ExternalProviderOperation,
|
||||
@@ -132,6 +133,7 @@ export const parsePaperclipQuestionSet = runner.parsePaperclipQuestionSet;
|
||||
export const parsePaperclipQuestionResponse =
|
||||
runner.parsePaperclipQuestionResponse;
|
||||
export const resolveQualifiedAcpxProfile = runner.resolveQualifiedAcpxProfile;
|
||||
export const resolveClaudeEffort = runner.resolveClaudeEffort;
|
||||
export const QUALIFIED_ACPX_PROFILES = runner.QUALIFIED_ACPX_PROFILES;
|
||||
export const QUALIFIED_ACPX_VERSION = runner.QUALIFIED_ACPX_VERSION;
|
||||
export const CURSOR_DISTRIBUTION_PINS = runner.CURSOR_DISTRIBUTION_PINS;
|
||||
|
||||
@@ -191,13 +191,18 @@ For each linked issue, either:
|
||||
|
||||
Choose the underlying harness in `adapterType`, such as `codex_local` or
|
||||
`claude_local`. New Codex agents use Paperclip Runner automatically on supported
|
||||
execution targets. Other harnesses retain their existing adapters. The saved
|
||||
response may therefore have `adapterType: paperclip_runner` and `provider: codex`;
|
||||
execution targets. New Claude Code agents use Paperclip Runner on Apple Silicon;
|
||||
other Claude targets retain Legacy until qualified. The native Claude profile is
|
||||
`provider: acpx`, `acpxAgent: claude`, with ordinary model `claude-opus-5-5`.
|
||||
Explicit models and existing saved runners remain intact. The saved response
|
||||
may therefore have `adapterType: paperclip_runner` and a resolved provider;
|
||||
use that returned configuration for later reads and edits.
|
||||
|
||||
The optional creation field `runner` accepts `auto`, `paperclip`, or `legacy`.
|
||||
Use `legacy` when the user needs custom Codex CLI settings the native runtime
|
||||
cannot honor. Setup and authentication errors never silently change runners.
|
||||
Use `legacy` when the user needs CLI settings the native runtime cannot honor.
|
||||
For Claude this includes Chrome, custom commands/state paths, warm-process
|
||||
controls, confinement and turn limits. Native Claude uses existing API-key and
|
||||
subscription connections. Its configured effort requires provider acknowledgment. Setup and authentication errors never silently change runners.
|
||||
Existing agents keep their saved execution after unrelated edits. An approved
|
||||
hire uses the configuration reviewed by the board. `inheritRuntimeFrom: caller`
|
||||
keeps its existing runtime inheritance and credential restrictions.
|
||||
|
||||
@@ -2198,3 +2198,19 @@ Private traces and database files must not be published. Unconfirmed cleanup
|
||||
always preserves recovery state regardless of this optional diagnostic flag.
|
||||
|
||||
The `file-edit-validate` fixture independently downloads the exact active artifact work product from the tested run. It requires the matching run-attributed attachment, filename, MIME type, recorded byte count and SHA-256, then compares the downloaded content to the expected bytes. A workspace file alone cannot satisfy this gate. Explicit failed-case diagnostic retention follows the final result after integrity, isolation and evidence checks, including incomplete publication.
|
||||
|
||||
|
||||
## Claude default rollout acceptance
|
||||
|
||||
The Claude first-task onboarding case asserts the wizard's saved native profile
|
||||
directly. It must not patch a Legacy agent into native execution after creation.
|
||||
Ordinary native defaults are qualified by execution target: the first rollout
|
||||
enables Apple Silicon only. API-key acceptance requires a useful downloadable
|
||||
file, a follow-up revision, independent downloads of both versions, persisted
|
||||
configuration and session continuity after reload/restart. Subscription setup
|
||||
uses the existing connection tools and receives separate task/follow-up proof.
|
||||
|
||||
Keep exact source, runtime executable digests and reports for each candidate.
|
||||
Effort or runtime changes require affected acceptance reruns. No automatic paid
|
||||
retry campaign is permitted under the separate $50 Claude qualification ceiling.
|
||||
Docker and expensive Rust builds run in cloud CI, never on the local Mac.
|
||||
@@ -1430,7 +1430,7 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [
|
||||
groups: ["onboarding"],
|
||||
profiles: runnerProfiles.filter(profile => ["legacy-codex", "legacy-claude", "runner-codex", "runner-acpx-claude"].includes(profile.id)),
|
||||
environments: [localEnvironment], tasks: firstTaskTasks, expectedMatrixSize: 52,
|
||||
definitionMetadata: { version: 5, runGrading: "evidenced-nonexecution-and-refusal", credentialPersistenceCheck: false, questionChoiceMinimum: 2, nativeSetup: "codex-production-default-claude-explicit-runtime-switch", productionInstructions: true, qualityGrading: "informational" },
|
||||
definitionMetadata: { version: 6, runGrading: "evidenced-nonexecution-and-refusal", credentialPersistenceCheck: false, questionChoiceMinimum: 2, nativeSetup: "qualified-platform-wizard-saved-default", productionInstructions: true, qualityGrading: "informational" },
|
||||
},
|
||||
{
|
||||
id: "agent-chat", label: "Persistent Agent Chat",
|
||||
|
||||
@@ -8,7 +8,6 @@ import { captureFirstTaskAttachments } from "./first-task-attachments.js";
|
||||
import { waitForFirstTaskReply } from "./first-task-replies.js";
|
||||
import { observeCompletionUpdate } from "./completion-update-flow.js";
|
||||
import {
|
||||
firstTaskNativeRuntimePatch,
|
||||
provisionFirstTaskFixtures,
|
||||
} from "./first-task-fixtures.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
@@ -228,7 +227,7 @@ export async function setupFirstTaskFixtures(input: {
|
||||
const wizardAdapter =
|
||||
execution.profile.credential === "OPENAI_API_KEY"
|
||||
? execution.profile.id === "runner-codex" ? "paperclip_runner" : "codex_local"
|
||||
: "claude_local";
|
||||
: execution.profile.id === "runner-acpx-claude" ? "paperclip_runner" : "claude_local";
|
||||
expect(agents[0].adapterType).toBe(wizardAdapter);
|
||||
if (execution.profile.id === "runner-codex") {
|
||||
expect(agents[0].adapterConfig?.provider).toBe("codex");
|
||||
@@ -239,37 +238,9 @@ export async function setupFirstTaskFixtures(input: {
|
||||
testedAdapterType: wizardAdapter,
|
||||
originalModel: agents[0].adapterConfig?.model ?? null,
|
||||
};
|
||||
// Claude remains an explicit native regression path in this Codex-only
|
||||
// rollout. Codex is never switched after onboarding: the saved default is
|
||||
// the execution under test.
|
||||
if (execution.profile.id === "runner-acpx-claude") {
|
||||
const runtimePatch = firstTaskNativeRuntimePatch(
|
||||
execution,
|
||||
fixtures,
|
||||
agents[0],
|
||||
);
|
||||
const migrated = await api.patch<Row>(
|
||||
`/api/agents/${agents[0].id}`,
|
||||
runtimePatch,
|
||||
);
|
||||
expect(migrated.adapterType).toBe("paperclip_runner");
|
||||
expect(migrated.adapterConfig?.provider).toBe(execution.profile.provider);
|
||||
expect(migrated.adapterConfig?.instructionsFilePath).toBe(
|
||||
agents[0].adapterConfig?.instructionsFilePath,
|
||||
);
|
||||
expect(migrated.adapterConfig?.paperclipSkillSync?.desiredSkills).toEqual(
|
||||
(
|
||||
runtimePatch.adapterConfig.paperclipSkillSync as {
|
||||
desiredSkills?: unknown[];
|
||||
}
|
||||
)?.desiredSkills,
|
||||
);
|
||||
fixtures.onboardingRuntime = {
|
||||
mode: "post-onboarding-runtime-switch",
|
||||
originalAdapterType: wizardAdapter,
|
||||
testedAdapterType: migrated.adapterType,
|
||||
originalModel: agents[0].adapterConfig?.model ?? null,
|
||||
};
|
||||
expect(agents[0].adapterConfig?.provider).toBe("acpx");
|
||||
expect(agents[0].adapterConfig?.acpxAgent).toBe("claude");
|
||||
}
|
||||
fixtures.agent = {
|
||||
id: agents[0].id,
|
||||
|
||||
@@ -212,7 +212,8 @@ export function getAdapterDisplay(type: string): AdapterDisplayInfo {
|
||||
export function agentDisplayAdapterType(agent: { adapterType: string; adapterConfig: Record<string, unknown> }): string {
|
||||
if (agent.adapterType !== "paperclip_runner") return agent.adapterType;
|
||||
if (agent.adapterConfig.provider === "openai_dot") return "openai_dot";
|
||||
return agentHarnessType(agent.adapterType, agent.adapterConfig) === "codex_local" ? "codex_local" : agent.adapterType;
|
||||
const harness = agentHarnessType(agent.adapterType, agent.adapterConfig);
|
||||
return ["codex_local", "claude_local"].includes(harness) ? harness : agent.adapterType;
|
||||
}
|
||||
|
||||
export function isKnownAdapterType(type: string): boolean {
|
||||
|
||||
@@ -35,7 +35,7 @@ const inputClass =
|
||||
const instructionsFileHint =
|
||||
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime. Note: Codex may still auto-apply repo-scoped AGENTS.md files from the workspace.";
|
||||
const defaultOpenCodeRunnerModel = "openrouter/deepseek/deepseek-v4-flash-0731";
|
||||
const defaultAcpxClaudeModel = "claude-sonnet-5";
|
||||
const defaultAcpxClaudeModel = "claude-opus-5-5";
|
||||
const defaultClaudeManagedModel = "claude-sonnet-5";
|
||||
const defaultAwsAgentCoreModel = "global.anthropic.claude-sonnet-4-6";
|
||||
const runnerHarnessOptions = [
|
||||
|
||||
@@ -849,7 +849,7 @@ describe("AgentConfigForm environment selector", () => {
|
||||
|
||||
it.each([
|
||||
["Codex", "codex", undefined, DEFAULT_CODEX_LOCAL_MODEL],
|
||||
["ACP agents", "acpx", "claude", "claude-sonnet-5"],
|
||||
["ACP agents", "acpx", "claude", "claude-opus-5-5"],
|
||||
["Claude Managed", "claude_managed", undefined, "claude-sonnet-5"],
|
||||
])("saves the %s harness default without the previous OpenCode model prefix", async (label, provider, acpxAgent, model) => {
|
||||
const accountList = vi.spyOn(aiConnectionsApi, "list").mockResolvedValue({
|
||||
@@ -996,8 +996,8 @@ describe("AgentConfigForm environment selector", () => {
|
||||
roots.push(existing.root);
|
||||
const created = await renderCreateForm(environments, { adapterType: "claude_local", model: "" });
|
||||
roots.push(created.root);
|
||||
expect(existing.container.textContent).toContain("Default (claude-opus-5)");
|
||||
expect(created.container.textContent).toContain("Default (claude-opus-5)");
|
||||
expect(existing.container.textContent).toContain("Default (claude-opus-5-5)");
|
||||
expect(created.container.textContent).toContain("Default (claude-opus-5-5)");
|
||||
expect(existing.onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useConnectionModels } from "./ai-connections/useConnectionModels";
|
||||
import { agentHarnessType, agentRunner, type AgentRunnerChoice } from "@paperclipai/shared";
|
||||
import { adaptersApi } from "../api/adapters";
|
||||
import { CodexRunnerSelect } from "./CodexRunnerSelect";
|
||||
import { AgentRunnerSelect } from "./AgentRunnerSelect";
|
||||
import { aiRoutingHarness } from "@paperclipai/shared";
|
||||
import { AiConnectionField } from "./ai-connections/AiConnectionField";
|
||||
import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema } from "@paperclipai/shared";
|
||||
@@ -609,6 +609,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
const effectiveConfig = isCreate ? props.values.adapterSchemaValues ?? {} : { ...config, ...overlay.adapterConfig };
|
||||
const harnessType = agentHarnessType(adapterType, effectiveConfig);
|
||||
const isCodexHarness = harnessType === "codex_local";
|
||||
const isRunnerHarness = isCodexHarness || harnessType === "claude_local";
|
||||
const rawCurrentDefaultEnvironmentId = isCreate
|
||||
? props.values.defaultEnvironmentId ?? ""
|
||||
: eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? "");
|
||||
@@ -622,13 +623,13 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
queryFn: () => adaptersApi.list({ companyId: selectedCompanyId!, environmentId: rawCurrentDefaultEnvironmentId || null }),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
const codexRunnerAvailability = runnerDiscovery.data?.find(item => item.type === "codex_local");
|
||||
const runnerAvailability = runnerDiscovery.data?.find(item => item.type === harnessType);
|
||||
const runner: AgentRunnerChoice = isCreate ? props.values.runner ?? "auto" : overlay.runner ?? agentRunner(adapterType);
|
||||
const fieldsAdapterType = isCodexHarness
|
||||
? (runner === "paperclip" || (runner === "auto" && codexRunnerAvailability?.defaultRunner === "paperclip") ? "paperclip_runner" : "codex_local")
|
||||
const fieldsAdapterType = isRunnerHarness
|
||||
? (runner === "paperclip" || (runner === "auto" && runnerAvailability?.defaultRunner === "paperclip") ? "paperclip_runner" : harnessType)
|
||||
: adapterType;
|
||||
const getCapabilities = useAdapterCapabilities();
|
||||
const adapterCaps = getCapabilities(isCodexHarness ? harnessType : adapterType);
|
||||
const adapterCaps = getCapabilities(isRunnerHarness ? harnessType : adapterType);
|
||||
const isDotRunner = adapterType === "paperclip_runner" && (isCreate ? props.values.adapterSchemaValues?.provider : eff("adapterConfig", "provider", config.provider)) === "openai_dot";
|
||||
const isLocal = !isDotRunner && (adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt);
|
||||
|
||||
@@ -918,7 +919,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
).data?.provider === "openrouter" ? "openrouter" : runnerProvider;
|
||||
const connectionModels = useConnectionModels(selectedCompanyId, isCreate ? undefined : aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data, aiRoutingHarness(adapterType, runnerProvider, eff("adapterConfig", "acpxAgent", config.acpxAgent)));
|
||||
// Fetch adapter models for the effective provider, including unsaved changes.
|
||||
const modelAdapterType = isCodexHarness ? harnessType : adapterType;
|
||||
const modelAdapterType = isRunnerHarness ? harnessType : adapterType;
|
||||
const modelQueryKey = selectedCompanyId
|
||||
? queryKeys.agents.adapterModels(selectedCompanyId, modelAdapterType, currentDefaultEnvironmentId || null, modelProvider)
|
||||
: ["agents", "none", "adapter-models", adapterType];
|
||||
@@ -970,10 +971,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
mode,
|
||||
isCreate,
|
||||
adapterType: fieldsAdapterType,
|
||||
hideRunnerHarness: isCodexHarness,
|
||||
values: isCreate ? props.values : null,
|
||||
hideRunnerHarness: isRunnerHarness,
|
||||
values: isCreate ? { ...props.values, ...(harnessType === "claude_local" && fieldsAdapterType === "paperclip_runner" ? { adapterSchemaValues: { ...props.values.adapterSchemaValues, provider: "acpx", acpxAgent: "claude" } } : {}) } : null,
|
||||
set: isCreate ? (patch: Partial<CreateConfigValues>) => props.onChange(patch) : null,
|
||||
config,
|
||||
config: harnessType === "claude_local" && fieldsAdapterType === "paperclip_runner" ? { ...config, provider: "acpx", acpxAgent: "claude" } : config,
|
||||
eff: eff as <T>(group: "adapterConfig", field: string, original: T) => T,
|
||||
// Harness transitions supply the new harness's default model. Resolve
|
||||
// user-selected model IDs in ModelDropdown, not through the previous
|
||||
@@ -1104,9 +1105,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
: adapterConfig.provider === "acpx" && adapterConfig.acpxAgent === "claude" ? "claude_local"
|
||||
: adapterType
|
||||
: adapterType;
|
||||
return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, agentId, aiConnection, environmentId, ...(isCodexHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) });
|
||||
return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, agentId, aiConnection, environmentId, ...(isRunnerHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) });
|
||||
}
|
||||
return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, aiConnection, environmentId, ...(isCodexHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) });
|
||||
return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, aiConnection, environmentId, ...(isRunnerHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) });
|
||||
},
|
||||
});
|
||||
const [testActionPending, setTestActionPending] = useState(false);
|
||||
@@ -1153,7 +1154,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
resetTestEnvironmentRef.current();
|
||||
setTestActionError(null);
|
||||
clearClaudeLoginClaimRef.current();
|
||||
}, [adapterType, runner, effectiveLoginEnvironmentId, runner === "auto" ? codexRunnerAvailability?.defaultRunner : undefined]);
|
||||
}, [adapterType, runner, effectiveLoginEnvironmentId, runner === "auto" ? runnerAvailability?.defaultRunner : undefined]);
|
||||
|
||||
// Show the login affordance only for a current sandbox adapter that declares a
|
||||
// login capability, and whose most recent Test result carries the canonical
|
||||
@@ -1164,7 +1165,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
const adapterSupportsSandboxLogin = adapterCaps.login != null;
|
||||
const testResult = testEnvironment.data;
|
||||
const testResultSupportsSandboxLogin =
|
||||
testResult != null && getCapabilities(isCodexHarness ? harnessType : testResult.adapterType).login != null;
|
||||
testResult != null && getCapabilities(isRunnerHarness ? harnessType : testResult.adapterType).login != null;
|
||||
const authMissingCheck =
|
||||
testResult && testResultSupportsSandboxLogin
|
||||
? testResult.checks.find((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE) ?? null
|
||||
@@ -1259,7 +1260,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
// environment id, so both are present here.
|
||||
login:
|
||||
showAdapterLogin && selectedCompanyId && effectiveLoginEnvironmentId
|
||||
? { companyId: selectedCompanyId, adapterType: isCodexHarness ? harnessType : adapterType, environmentId: effectiveLoginEnvironmentId }
|
||||
? { companyId: selectedCompanyId, adapterType: isRunnerHarness ? harnessType : adapterType, environmentId: effectiveLoginEnvironmentId }
|
||||
: null,
|
||||
});
|
||||
return () => {
|
||||
@@ -1297,7 +1298,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
}
|
||||
|
||||
const thinkingEffortKey =
|
||||
isCodexHarness
|
||||
isCodexHarness || (adapterType === "paperclip_runner" && harnessType === "claude_local")
|
||||
? "modelReasoningEffort"
|
||||
: adapterType === "cursor"
|
||||
? "mode"
|
||||
@@ -1319,19 +1320,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
? kimiThinkingEffortOptions
|
||||
: adapterType === "pi_local"
|
||||
? [{ id: "", label: "Auto" }, ...["off", "minimal", "low", "medium", "high", "xhigh"].map(id => ({ id, label: id }))]
|
||||
: adapterType === "claude_local" || adapterType === "grok_local"
|
||||
? [{ id: "", label: "Auto" }, ...setupEfforts(adapterType, currentModelId).map((id) => ({
|
||||
: harnessType === "claude_local" || adapterType === "grok_local"
|
||||
? [{ id: "", label: "Auto" }, ...setupEfforts(harnessType, currentModelId).map((id) => ({
|
||||
id,
|
||||
label: id === "xhigh" ? "X-High" : id[0].toUpperCase() + id.slice(1),
|
||||
}))]
|
||||
: claudeThinkingEffortOptions;
|
||||
const currentThinkingEffort = isCreate
|
||||
? val!.thinkingEffort
|
||||
: isCodexHarness
|
||||
: isCodexHarness || (adapterType === "paperclip_runner" && harnessType === "claude_local")
|
||||
? eff(
|
||||
"adapterConfig",
|
||||
thinkingEffortKey,
|
||||
String(config[thinkingEffortKey] ?? config.reasoningEffort ?? config.modelReasoningEffort ?? ""),
|
||||
String(config[thinkingEffortKey] ?? config.reasoningEffort ?? config.modelReasoningEffort ?? config.effort ?? ""),
|
||||
)
|
||||
: adapterType === "cursor"
|
||||
? eff("adapterConfig", "mode", String(config.mode ?? ""))
|
||||
@@ -1340,7 +1341,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
: eff("adapterConfig", thinkingEffortKey, String(config[thinkingEffortKey] ?? ""));
|
||||
const showThinkingEffort = adapterType !== "gemini_local"
|
||||
&& adapterType !== "cursor_cloud"
|
||||
&& (adapterType !== "paperclip_runner" || isCodexHarness);
|
||||
&& (adapterType !== "paperclip_runner" || isRunnerHarness);
|
||||
const codexSearchEnabled = adapterType === "codex_local"
|
||||
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
|
||||
: false;
|
||||
@@ -1628,7 +1629,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
{showAdapterTypeField && (
|
||||
<Field label="Harness" hint={help.adapterType}>
|
||||
<AdapterTypeDropdown
|
||||
value={isDotRunner ? "openai_dot" : isCodexHarness ? harnessType : adapterType}
|
||||
value={isDotRunner ? "openai_dot" : isRunnerHarness ? harnessType : adapterType}
|
||||
disabledTypes={adapterPickerDisabledTypes}
|
||||
openAiDotEnabled={experimentalSettings?.enableOpenAiDot === true}
|
||||
nativeRunnerEnabled={experimentalSettings?.enableNativeRunner === true}
|
||||
@@ -1664,7 +1665,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
setOverlay((prev) => ({
|
||||
...prev,
|
||||
adapterType: t,
|
||||
runner: t === "codex_local" ? "auto" : undefined,
|
||||
runner: ["codex_local", "claude_local"].includes(t) ? "auto" : undefined,
|
||||
adapterConfig: {
|
||||
model:
|
||||
t === "gemini_local"
|
||||
@@ -1720,7 +1721,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
<AdapterLoginPanel
|
||||
key={`${adapterType}:${effectiveLoginEnvironmentId}`}
|
||||
companyId={selectedCompanyId!}
|
||||
adapterType={isCodexHarness ? harnessType : adapterType}
|
||||
adapterType={isRunnerHarness ? harnessType : adapterType}
|
||||
environmentId={effectiveLoginEnvironmentId!}
|
||||
onStored={isCreate ? handleClaudeLoginStored : handleClaudeLoginStoredEdit}
|
||||
onApplyStored={
|
||||
@@ -1762,8 +1763,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
loadingModels={connectionModels?.isLoading ?? fetchingModels}
|
||||
value={currentModelId}
|
||||
onChange={(v) => {
|
||||
const supportedEfforts = setupEfforts(isCodexHarness ? harnessType : adapterType, v);
|
||||
const clearUnsupportedEffort = ["codex_local", "claude_local", "grok_local"].includes(isCodexHarness ? harnessType : adapterType)
|
||||
const supportedEfforts = setupEfforts(isRunnerHarness ? harnessType : adapterType, v);
|
||||
const clearUnsupportedEffort = ["codex_local", "claude_local", "grok_local"].includes(isRunnerHarness ? harnessType : adapterType)
|
||||
&& Boolean(currentThinkingEffort)
|
||||
&& !supportedEfforts.includes(String(currentThinkingEffort));
|
||||
if (isCreate) {
|
||||
@@ -1831,7 +1832,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
? set!({ thinkingEffort: v })
|
||||
: (() => {
|
||||
mark("adapterConfig", thinkingEffortKey, v || undefined);
|
||||
if (isCodexHarness) mark("adapterConfig", thinkingEffortKey === "reasoningEffort" ? "modelReasoningEffort" : "reasoningEffort", undefined);
|
||||
if (isRunnerHarness) {
|
||||
for (const key of ["modelReasoningEffort", "reasoningEffort", "effort"]) if (key !== thinkingEffortKey) mark("adapterConfig", key, undefined);
|
||||
}
|
||||
})()
|
||||
}
|
||||
open={thinkingEffortOpen}
|
||||
@@ -1893,10 +1896,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
onToggle={() => setConfigurationAdvancedOpen(!configurationAdvancedOpen)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{isCodexHarness && <CodexRunnerSelect value={runner}
|
||||
{isRunnerHarness && <AgentRunnerSelect value={runner}
|
||||
pending={runnerDiscovery.isFetching} error={runnerDiscovery.error?.message} onRetry={() => { void runnerDiscovery.refetch(); }}
|
||||
defaultRunner={codexRunnerAvailability?.defaultRunner}
|
||||
supportedRunners={codexRunnerAvailability?.supportedRunners}
|
||||
defaultRunner={runnerAvailability?.defaultRunner}
|
||||
supportedRunners={runnerAvailability?.supportedRunners}
|
||||
onChange={next => isCreate ? set!({ runner: next }) : setOverlay(prev => ({ ...prev, runner: next }))} />}
|
||||
{isLocal && (<> {/*
|
||||
The command names a binary on the execution host, so the
|
||||
|
||||
@@ -4,7 +4,7 @@ import { SelectPopover } from "./ui/select";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
/** The override is request-level. Automatic selection is resolved by the server. */
|
||||
export function CodexRunnerSelect({
|
||||
export function AgentRunnerSelect({
|
||||
value,
|
||||
onChange,
|
||||
defaultRunner,
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { agentHarnessType, agentRunner, type AgentRunnerChoice } from "@paperclipai/shared";
|
||||
import { adaptersApi } from "@/api/adapters";
|
||||
import { CodexRunnerSelect } from "./CodexRunnerSelect";
|
||||
import { AgentRunnerSelect } from "./AgentRunnerSelect";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
@@ -68,12 +68,12 @@ export function ConfigureBuiltInAgentModal({
|
||||
const { definition } = state;
|
||||
|
||||
const [adapterType, setAdapterType] = useState<string>(
|
||||
() => state.agent && agentHarnessType(state.agent.adapterType, state.agent.adapterConfig) === "codex_local" ? "codex_local" : state.agent?.adapterType ?? defaultAdapterType(state),
|
||||
() => state.agent && ["codex_local", "claude_local"].includes(agentHarnessType(state.agent.adapterType, state.agent.adapterConfig)) ? agentHarnessType(state.agent.adapterType, state.agent.adapterConfig) : state.agent?.adapterType ?? defaultAdapterType(state),
|
||||
);
|
||||
const [runner, setRunner] = useState<AgentRunnerChoice | undefined>();
|
||||
const environmentId = state.agent?.defaultEnvironmentId ?? null;
|
||||
const adapters = useQuery({ queryKey: queryKeys.adapters.availability(companyId, environmentId), queryFn: () => adaptersApi.list({ companyId, environmentId }), enabled: open });
|
||||
const codexAvailability = adapters.data?.find(item => item.type === "codex_local");
|
||||
const runnerAvailability = adapters.data?.find(item => item.type === adapterType);
|
||||
const [model, setModel] = useState<string>(() => {
|
||||
const config = state.agent?.adapterConfig;
|
||||
const configuredModel = typeof config === "object" && config !== null
|
||||
@@ -145,7 +145,7 @@ export function ConfigureBuiltInAgentModal({
|
||||
: { adapterType, adapterConfig };
|
||||
const result = await builtInAgentsApi.provision(companyId, definition.key, {
|
||||
...adapterInput,
|
||||
...(adapterType === "codex_local" && runner !== undefined ? { runner } : {}),
|
||||
...(["codex_local", "claude_local"].includes(adapterType) && runner !== undefined ? { runner } : {}),
|
||||
...(budgetMonthlyCents !== undefined ? { budgetMonthlyCents } : {}),
|
||||
});
|
||||
return result;
|
||||
@@ -191,11 +191,11 @@ export function ConfigureBuiltInAgentModal({
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{adapterType === "codex_local" && <details className="space-y-3">
|
||||
{["codex_local", "claude_local"].includes(adapterType) && <details className="space-y-3">
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
|
||||
<CodexRunnerSelect value={runner ?? (state.agent ? agentRunner(state.agent.adapterType) : "auto")}
|
||||
<AgentRunnerSelect value={runner ?? (state.agent ? agentRunner(state.agent.adapterType) : "auto")}
|
||||
pending={adapters.isFetching} error={adapters.error?.message} onRetry={() => { void adapters.refetch(); }}
|
||||
defaultRunner={codexAvailability?.defaultRunner} supportedRunners={codexAvailability?.supportedRunners}
|
||||
defaultRunner={runnerAvailability?.defaultRunner} supportedRunners={runnerAvailability?.supportedRunners}
|
||||
onChange={setRunner} />
|
||||
</details>}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AgentRunnerChoice } from "@paperclipai/shared";
|
||||
import { agentHarnessType } from "@paperclipai/shared";
|
||||
import { adaptersApi } from "../api/adapters";
|
||||
import { CodexRunnerSelect } from "./CodexRunnerSelect";
|
||||
import { AgentRunnerSelect } from "./AgentRunnerSelect";
|
||||
import { healthApi } from "@/api/health";
|
||||
import { LocalProviderLoginInstructions } from "./AdapterLoginChrome";
|
||||
import { useLocalAiLogin } from "./ai-connections/useLocalAiLogin";
|
||||
@@ -600,7 +600,7 @@ function OnboardingWizardInner({
|
||||
restoreOnboardingAdapterType(saved?.adapterType, saved?.runnerProvider),
|
||||
);
|
||||
const [runner, setRunner] = useState<AgentRunnerChoice>(() => saved?.runner === "legacy" || saved?.runner === "paperclip"
|
||||
? saved.runner : saved?.adapterType === "paperclip_runner" && (saved?.runnerProvider === undefined || saved?.runnerProvider === "codex") ? "paperclip" : "auto");
|
||||
? saved.runner : saved?.adapterType === "paperclip_runner" && (saved?.runnerProvider === undefined || ["codex", "claude", "acpx"].includes(String(saved?.runnerProvider))) ? "paperclip" : "auto");
|
||||
/**
|
||||
* Whether a model source has been chosen, as opposed to which one
|
||||
* `adapterType` happens to hold.
|
||||
@@ -629,7 +629,7 @@ function OnboardingWizardInner({
|
||||
// Recover the harness and runner separately. Host command overrides from an
|
||||
// old native draft cannot describe the provider; portable model/cwd survive.
|
||||
const [model, setModel] = useState(
|
||||
savedNativeRunnerDraft && saved?.runnerProvider !== undefined && saved.runnerProvider !== "codex" ? "" : (saved?.model as string) ?? "",
|
||||
savedNativeRunnerDraft && saved?.runnerProvider !== undefined && !["codex", "claude", "acpx"].includes(String(saved.runnerProvider)) ? "" : (saved?.model as string) ?? "",
|
||||
);
|
||||
const [command, setCommand] = useState(
|
||||
savedNativeRunnerDraft ? "" : (saved?.command as string) ?? "",
|
||||
@@ -1550,7 +1550,7 @@ function OnboardingWizardInner({
|
||||
setAdapterEnvResult(null);
|
||||
adapterEnvResultAppliedStoredLoginRef.current = false;
|
||||
setAdapterEnvError(null);
|
||||
}, [step, adapterType, runner, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id, createdCompanyId, resolvedLoginEnvironmentId, runner === "auto" ? runnerDiscovery.data?.find(item => item.type === "codex_local")?.defaultRunner : undefined]);
|
||||
}, [step, adapterType, runner, model, command, args, url, credentialMode, apiKey, selectedSavedKey, selectedApiKey?.id, savedSubscription?.id, createdCompanyId, resolvedLoginEnvironmentId, runner === "auto" ? runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner : undefined]);
|
||||
|
||||
/**
|
||||
* Leaving the step puts the row back to a question.
|
||||
@@ -1963,7 +1963,7 @@ function OnboardingWizardInner({
|
||||
adapterType,
|
||||
{
|
||||
adapterConfig: adapterConfigOverride ?? buildAdapterConfig(),
|
||||
...(adapterType === "codex_local" && runner !== "auto" ? { runner } : {}),
|
||||
...(["codex_local", "claude_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
|
||||
...(managedBindingForStep() ? { aiConnection: managedBindingForStep() } : {}),
|
||||
environmentId,
|
||||
}
|
||||
@@ -2212,7 +2212,7 @@ function OnboardingWizardInner({
|
||||
role: agentRole,
|
||||
adapterType,
|
||||
adapterConfig: hireAdapterConfig,
|
||||
...(adapterType === "codex_local" && runner !== "auto" ? { runner } : {}),
|
||||
...(["codex_local", "claude_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
|
||||
...(shouldApplyStoredClaudeLogin ? { applyStoredClaudeLogin: true } : {}),
|
||||
// The server owns what the first agent is told now: this marker seeds
|
||||
// the chief-of-staff persona over the agent's entry instruction file.
|
||||
@@ -2908,12 +2908,12 @@ function OnboardingWizardInner({
|
||||
) : null}
|
||||
</motion.div>
|
||||
|
||||
{sourceSelected && adapterType === "codex_local" && <details className="space-y-3">
|
||||
{sourceSelected && ["codex_local", "claude_local"].includes(adapterType) && <details className="space-y-3">
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
|
||||
<CodexRunnerSelect value={runner} disabled={loading || adapterEnvLoading}
|
||||
<AgentRunnerSelect value={runner} disabled={loading || adapterEnvLoading}
|
||||
pending={runnerDiscovery.isFetching} error={runnerDiscovery.error?.message} onRetry={() => { void runnerDiscovery.refetch(); }}
|
||||
defaultRunner={runnerDiscovery.data?.find(item => item.type === "codex_local")?.defaultRunner}
|
||||
supportedRunners={runnerDiscovery.data?.find(item => item.type === "codex_local")?.supportedRunners}
|
||||
defaultRunner={runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner}
|
||||
supportedRunners={runnerDiscovery.data?.find(item => item.type === adapterType)?.supportedRunners}
|
||||
onChange={setRunner} />
|
||||
</details>}
|
||||
|
||||
|
||||
@@ -225,8 +225,6 @@ export function AgentBasicsDialog({
|
||||
placeholder="Choose a native harness…"
|
||||
onValueChange={provider => { setRunnerProvider(provider); setAdapterType("paperclip_runner"); }}
|
||||
options={[
|
||||
{ value: "codex", label: "Codex (Paperclip Runner)" },
|
||||
{ value: "claude", label: "Claude Code (Paperclip Runner)" },
|
||||
{ value: "grok", label: "Grok Build (Paperclip Runner)" },
|
||||
{ value: "opencode", label: "OpenCode (Paperclip Runner)" },
|
||||
]} />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useAgentAppearanceDraft } from "../../hooks/useAgentAppearanceDraft";
|
||||
import { AiConnectionField, aiProviderForAdapter } from "../ai-connections/AiConnectionField";
|
||||
import type { AiConnectionBinding } from "@paperclipai/shared";
|
||||
import type { AgentRunnerChoice } from "@paperclipai/shared";
|
||||
import { CodexRunnerSelect } from "../CodexRunnerSelect";
|
||||
import { AgentRunnerSelect } from "../AgentRunnerSelect";
|
||||
import { SelectPopover } from "../ui/select";
|
||||
import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local";
|
||||
import {
|
||||
@@ -90,9 +90,9 @@ export function NewAgentSetup() {
|
||||
key={`${selectedCompanyId}:${params.get("name")}:${params.get("adapterType")}:${params.get("runnerProvider")}:${params.get("runner")}`}
|
||||
companyId={selectedCompanyId}
|
||||
name={params.get("name") ?? ""}
|
||||
adapterType={params.get("adapterType") === "paperclip_runner" && (params.get("runnerProvider") ?? "codex") === "codex" ? "codex_local" : params.get("adapterType") ?? ""}
|
||||
initialRunner={params.get("runner") ?? (params.get("adapterType") === "paperclip_runner" && (params.get("runnerProvider") ?? "codex") === "codex" ? "paperclip" : "auto")}
|
||||
preserveNativeCodex={params.get("adapterType") === "paperclip_runner" && (params.get("runnerProvider") ?? "codex") === "codex"}
|
||||
adapterType={params.get("adapterType") === "paperclip_runner" && ["codex", "claude"].includes(params.get("runnerProvider") ?? "codex") ? params.get("runnerProvider") === "claude" ? "claude_local" : "codex_local" : params.get("adapterType") ?? ""}
|
||||
initialRunner={params.get("runner") ?? (params.get("adapterType") === "paperclip_runner" && ["codex", "claude"].includes(params.get("runnerProvider") ?? "codex") ? "paperclip" : "auto")}
|
||||
preserveNativeHarness={params.get("adapterType") === "paperclip_runner" && ["codex", "claude"].includes(params.get("runnerProvider") ?? "codex")}
|
||||
runnerProvider={params.get("runnerProvider") ?? "codex"}
|
||||
createdAgentId={params.get("createdAgentId")}
|
||||
/>
|
||||
@@ -105,7 +105,7 @@ function Setup({
|
||||
adapterType,
|
||||
runnerProvider,
|
||||
initialRunner,
|
||||
preserveNativeCodex,
|
||||
preserveNativeHarness,
|
||||
createdAgentId,
|
||||
}: {
|
||||
companyId: string;
|
||||
@@ -113,7 +113,7 @@ function Setup({
|
||||
adapterType: string;
|
||||
runnerProvider: string;
|
||||
initialRunner: string;
|
||||
preserveNativeCodex: boolean;
|
||||
preserveNativeHarness: boolean;
|
||||
createdAgentId: string | null;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
@@ -121,7 +121,7 @@ function Setup({
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const appearanceDraft = useAgentAppearanceDraft(`${companyId}:new-agent`);
|
||||
const [runner, setRunner] = useState<AgentRunnerChoice>(initialRunner === "legacy" || initialRunner === "paperclip" ? initialRunner : "auto");
|
||||
const requestAdapterType = preserveNativeCodex && runner === "paperclip" ? "paperclip_runner" : adapterType;
|
||||
const requestAdapterType = preserveNativeHarness && runner === "paperclip" ? "paperclip_runner" : adapterType;
|
||||
const isRunner = adapterType === "paperclip_runner";
|
||||
const isDot = isRunner && runnerProvider === "openai_dot";
|
||||
const brandType = isDot ? "openai_dot" : isRunner
|
||||
@@ -287,7 +287,7 @@ function Setup({
|
||||
queryKey: queryKeys.adapters.availability(companyId, environmentId),
|
||||
queryFn: () => adaptersApi.list({ companyId, environmentId }),
|
||||
});
|
||||
const defaultRunner = adapters.data?.find(adapter => adapter.type === "codex_local")?.defaultRunner;
|
||||
const defaultRunner = adapters.data?.find(adapter => adapter.type === adapterType)?.defaultRunner;
|
||||
useEffect(() => {
|
||||
resetTest();
|
||||
}, [companyId, environmentId, runner === "auto" ? defaultRunner : undefined]);
|
||||
@@ -342,7 +342,7 @@ function Setup({
|
||||
) && (requestAdapterType !== "paperclip_runner" || adapterType === "paperclip_runner" || adapterInventory.data?.some(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled));
|
||||
const ready = Boolean(
|
||||
available &&
|
||||
(adapterType !== "codex_local" || runner !== "auto" || Boolean(adapters.data)) &&
|
||||
(!["codex_local", "claude_local"].includes(adapterType) || runner !== "auto" || Boolean(adapters.data)) &&
|
||||
!environmentError &&
|
||||
!envs.isPending &&
|
||||
!settings.isPending &&
|
||||
@@ -383,9 +383,9 @@ function Setup({
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const config = getUIAdapter(adapterType).buildAdapterConfig(values);
|
||||
if (preserveNativeCodex && runner === "paperclip") {
|
||||
Object.assign(config, { provider: "codex", codexPermissionMode: "never", lifecycleMode: "per_turn" });
|
||||
const config = getUIAdapter(preserveNativeHarness && runner === "paperclip" ? "paperclip_runner" : adapterType).buildAdapterConfig(preserveNativeHarness && runner === "paperclip" ? { ...values, adapterSchemaValues: runnerProvider === "claude" ? { provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all" } : { provider: "codex" } } : values);
|
||||
if (preserveNativeHarness && runner === "paperclip") {
|
||||
Object.assign(config, runnerProvider === "claude" ? { provider: "acpx", acpxAgent: "claude", acpxPermissionMode: "approve-all", lifecycleMode: "per_turn" } : { provider: "codex", codexPermissionMode: "never", lifecycleMode: "per_turn" });
|
||||
}
|
||||
if (isRunner)
|
||||
Object.assign(config, {
|
||||
@@ -473,7 +473,7 @@ function Setup({
|
||||
companyId,
|
||||
adapterType: requestAdapterType,
|
||||
providerAdapter: brandType,
|
||||
...(adapterType === "codex_local" && runner !== "auto" ? { runner } : {}),
|
||||
...(["codex_local", "claude_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
|
||||
adapterConfig: config,
|
||||
testCredentials: pendingCredentials(nextConnection),
|
||||
aiConnection: runtimeAiBinding ?? nextConnection?.aiConnection,
|
||||
@@ -546,7 +546,7 @@ function Setup({
|
||||
role: existing.length ? "general" : "ceo",
|
||||
...(leader ? { reportsTo: leader.id } : {}),
|
||||
adapterType: requestAdapterType,
|
||||
...(adapterType === "codex_local" && runner !== "auto" ? { runner } : {}),
|
||||
...(["codex_local", "claude_local"].includes(adapterType) && runner !== "auto" ? { runner } : {}),
|
||||
adapterConfig: config,
|
||||
defaultEnvironmentId:
|
||||
environmentOverride ||
|
||||
@@ -814,12 +814,12 @@ function Setup({
|
||||
setScreen("runtime");
|
||||
}}
|
||||
/> : <p role="status" className="text-sm text-muted-foreground">Loading connection settings…</p>}
|
||||
{adapterType === "codex_local" && <details className="mt-5 space-y-3">
|
||||
{["codex_local", "claude_local"].includes(adapterType) && <details className="mt-5 space-y-3">
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
|
||||
<CodexRunnerSelect value={runner} disabled={busy}
|
||||
<AgentRunnerSelect value={runner} disabled={busy}
|
||||
pending={adapters.isFetching} error={adapters.error?.message} onRetry={() => { void adapters.refetch(); }}
|
||||
defaultRunner={adapters.data?.find(item => item.type === "codex_local")?.defaultRunner}
|
||||
supportedRunners={adapters.data?.find(item => item.type === "codex_local")?.supportedRunners}
|
||||
defaultRunner={adapters.data?.find(item => item.type === adapterType)?.defaultRunner}
|
||||
supportedRunners={adapters.data?.find(item => item.type === adapterType)?.supportedRunners}
|
||||
onChange={value => { setRunner(value); resetTest(); }} />
|
||||
</details>}
|
||||
</OnboardingCard>
|
||||
@@ -1165,12 +1165,12 @@ function Setup({
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
{adapterType === "codex_local" && <details className="space-y-3">
|
||||
{["codex_local", "claude_local"].includes(adapterType) && <details className="space-y-3">
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
|
||||
<CodexRunnerSelect value={runner} disabled={busy}
|
||||
<AgentRunnerSelect value={runner} disabled={busy}
|
||||
pending={adapters.isFetching} error={adapters.error?.message} onRetry={() => { void adapters.refetch(); }}
|
||||
defaultRunner={adapters.data?.find(item => item.type === "codex_local")?.defaultRunner}
|
||||
supportedRunners={adapters.data?.find(item => item.type === "codex_local")?.supportedRunners}
|
||||
defaultRunner={adapters.data?.find(item => item.type === adapterType)?.defaultRunner}
|
||||
supportedRunners={adapters.data?.find(item => item.type === adapterType)?.supportedRunners}
|
||||
onChange={value => { setRunner(value); resetTest(); }} />
|
||||
</details>}
|
||||
</section>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type Ref } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Check, ChevronDown, Plus, RotateCcw, Search, X, Zap } from "lucide-react";
|
||||
import { aiRuntimeConnectionBindingSchema, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared";
|
||||
import { aiRuntimeConnectionBindingSchema, agentHarnessType, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared";
|
||||
import { agentsApi, type AdapterModel } from "@/api/agents";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -51,7 +51,7 @@ const HARNESS_LABELS: Record<string, string> = {
|
||||
|
||||
function harnessLabel(agent: Agent | undefined): string {
|
||||
if (!agent) return "Choose an agent";
|
||||
const harness = HARNESS_LABELS[agent.adapterType] ?? agent.adapterType;
|
||||
const harness = HARNESS_LABELS[agentHarnessType(agent.adapterType, agent.adapterConfig)] ?? agent.adapterType;
|
||||
const provider = composerCatalogProvider(agent);
|
||||
const binding = aiRuntimeConnectionBindingSchema.safeParse(agent?.runtimeConfig?.aiConnection).data;
|
||||
const poolId = binding?.mode === "router" ? binding.connectionId : undefined;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { claudeLocalReasoningEffortsForModel } from "@paperclipai/adapter-claude
|
||||
import { resolvePaperclipRunnerModel } from "@paperclipai/adapter-utils";
|
||||
import { DEFAULT_GROK_LOCAL_MODEL, grokLocalReasoningEffortsForModel } from "@paperclipai/adapter-grok-local";
|
||||
import { DEFAULT_KIMI_LOCAL_MODEL, modelSupportsEffort, KIMI_SUPPORTED_EFFORTS } from "@paperclipai/adapter-kimi-local";
|
||||
import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared";
|
||||
import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema, agentHarnessType, type Agent, type IssueAssigneeAdapterOverrides } from "@paperclipai/shared";
|
||||
|
||||
export interface ComposerRunSettings {
|
||||
model: string | null;
|
||||
@@ -65,12 +65,12 @@ export function composerEfforts(agent: Agent | undefined, model: string, catalog
|
||||
: agent.adapterType === "kimi_local" ? DEFAULT_KIMI_LOCAL_MODEL : "");
|
||||
if (!effectiveModel) return [];
|
||||
const pool = aiRuntimeConnectionBindingSchema.safeParse(agent.runtimeConfig?.aiConnection).data?.mode === "router";
|
||||
if (pool && agent.adapterType === "paperclip_runner") return isCodexLocalKnownModel(effectiveModel) ? codexLocalReasoningEffortsForModel(effectiveModel) : [];
|
||||
if (pool && agent.adapterType === "paperclip_runner") return isCodexLocalKnownModel(effectiveModel) ? codexLocalReasoningEffortsForModel(effectiveModel) : /^claude-/.test(effectiveModel) ? claudeLocalReasoningEffortsForModel(effectiveModel) : [];
|
||||
if (agent.adapterType === "codex_local" || (agent.adapterType === "paperclip_runner" && composerCatalogProvider(agent) === "codex")) {
|
||||
return isCodexLocalKnownModel(effectiveModel) ? codexLocalReasoningEffortsForModel(effectiveModel) : [];
|
||||
}
|
||||
if (!catalogIds.includes(effectiveModel)) return [];
|
||||
if (agent.adapterType === "claude_local") return claudeLocalReasoningEffortsForModel(effectiveModel);
|
||||
if (agentHarnessType(agent.adapterType, agent.adapterConfig) === "claude_local") return claudeLocalReasoningEffortsForModel(effectiveModel);
|
||||
if (agent.adapterType === "grok_local") return grokLocalReasoningEffortsForModel(effectiveModel);
|
||||
if (agent.adapterType === "pi_local") return ["off", "minimal", "low", "medium", "high", "xhigh"];
|
||||
if (agent.adapterType === "kimi_local" && typeof agent.adapterConfig.engine === "string"
|
||||
|
||||
@@ -575,13 +575,14 @@ function importAdapterValues(
|
||||
sourceAdapterType?: string,
|
||||
runner?: AgentRunnerChoice,
|
||||
): CreateConfigValues {
|
||||
const nativeCodex = sourceAdapterType === "paperclip_runner" && choice === "codex_local";
|
||||
const nativeHarness = sourceAdapterType === "paperclip_runner" && ["codex_local", "claude_local"].includes(choice);
|
||||
const nativeCodex = nativeHarness && choice === "codex_local";
|
||||
const savedBypass = sourceConfig?.dangerouslyBypassApprovalsAndSandbox
|
||||
?? (nativeCodex ? undefined : sourceConfig?.dangerouslyBypassSandbox);
|
||||
return {
|
||||
...defaultCreateValues,
|
||||
adapterType: choice === "openai_dot" ? "paperclip_runner" : choice,
|
||||
...(runner !== undefined ? { runner } : nativeCodex ? { runner: agentRunner("paperclip_runner") } : {}),
|
||||
...(runner !== undefined ? { runner } : nativeHarness ? { runner: agentRunner("paperclip_runner") } : {}),
|
||||
...(sourceConfig && choice === "codex_local" ? {
|
||||
model: typeof sourceConfig.model === "string" ? sourceConfig.model : "",
|
||||
thinkingEffort: String(sourceConfig.reasoningEffort ?? sourceConfig.modelReasoningEffort ?? ""),
|
||||
@@ -591,6 +592,7 @@ function importAdapterValues(
|
||||
? (sourceConfig.codexPermissionMode ?? PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES.codex.defaultMode) === "never"
|
||||
: DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||
} : {}),
|
||||
...(sourceConfig && choice === "claude_local" ? { model: typeof sourceConfig.model === "string" ? sourceConfig.model : "", thinkingEffort: String(sourceConfig.modelReasoningEffort ?? sourceConfig.reasoningEffort ?? sourceConfig.effort ?? "") } : {}),
|
||||
...(sourceAdapterType === "paperclip_runner" && choice !== "openai_dot"
|
||||
? { adapterSchemaValues: { ...sourceConfig } } : {}),
|
||||
...(choice === "openai_dot" ? {
|
||||
@@ -1670,14 +1672,15 @@ export function CompanyImport() {
|
||||
return importPreview.manifest.agents.map((a) => {
|
||||
const nativeCodex = a.adapterType === "paperclip_runner"
|
||||
&& (a.adapterConfig?.provider === "codex" || a.adapterConfig?.provider === undefined);
|
||||
const nativeHarness = nativeCodex || (a.adapterType === "paperclip_runner" && agentHarnessType(a.adapterType, a.adapterConfig) === "claude_local");
|
||||
const adapterChoice = a.adapterType === "paperclip_runner" && a.adapterConfig?.provider === "openai_dot"
|
||||
? "openai_dot" : nativeCodex ? agentHarnessType(a.adapterType, a.adapterConfig) : a.adapterType;
|
||||
? "openai_dot" : nativeHarness ? agentHarnessType(a.adapterType, a.adapterConfig) : a.adapterType;
|
||||
let fallbackAdapterType: string | null = null;
|
||||
let availabilityWarning: string | undefined;
|
||||
if (a.adapterType === "paperclip_runner") {
|
||||
const available = nativeCodex ? runnerInstalled : adapterChoice === "openai_dot" ? dotAvailable : nativeRunnerAvailable;
|
||||
if (!available && (availableAdapterTypes || !nativeCodex)) {
|
||||
availabilityWarning = nativeCodex
|
||||
const available = nativeHarness ? runnerInstalled : adapterChoice === "openai_dot" ? dotAvailable : nativeRunnerAvailable;
|
||||
if (!available && (availableAdapterTypes || !nativeHarness)) {
|
||||
availabilityWarning = nativeHarness
|
||||
? "Paperclip Runner is unavailable here. Choose a runner or another harness explicitly."
|
||||
: `Paperclip Runner provider ${String(a.adapterConfig?.provider)} requires its experimental feature to be enabled. This import keeps its saved harness until you choose another one.`;
|
||||
}
|
||||
|
||||
@@ -412,11 +412,21 @@ describe("New agent setup", () => {
|
||||
await click("Finish setup");
|
||||
expect(api.hire.mock.calls[0][1].defaultEnvironmentId).toBe("grok-sandbox");
|
||||
});
|
||||
it.each([false, true])("keeps non-Codex runner setup links gated when the experiment is disabled (cloud=%s)", async (cloud) => {
|
||||
it.each([false, true])("keeps other native setup links gated when the experiment is disabled (cloud=%s)", async (cloud) => {
|
||||
cache.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: cloud } });
|
||||
settings.getExperimental.mockResolvedValue({ enableNativeRunner: false });
|
||||
await render("paperclip_runner", "grok");
|
||||
expect(container.textContent).toContain("This adapter is unavailable");
|
||||
expect(api.hire).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([false, true])("restores native Claude setup intent and its connection flow with experiment=false (cloud=%s)", async cloud => {
|
||||
cache.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: cloud } });
|
||||
settings.getExperimental.mockResolvedValue({ enableNativeRunner: false });
|
||||
await render("paperclip_runner", "claude");
|
||||
expect(container.textContent).toContain("This adapter is unavailable");
|
||||
expect(container.textContent).not.toContain("This adapter is unavailable");
|
||||
expect(container.textContent).toContain("Claude Code");
|
||||
expect(container.textContent).toContain("Paperclip Runner");
|
||||
expect(container.textContent).toContain("Connect a model");
|
||||
expect(api.hire).not.toHaveBeenCalled();
|
||||
});
|
||||
it("blocks direct setup links for unsupported Cloud adapters", async () => {
|
||||
|
||||
Reference in new issue
Block a user