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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/control-plane/src/http/dto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Cron } from 'croner'
import { RESERVED_AGENT_SLUGS } from '../../domain/reserved-agent-slugs.js'
import {
AgentMemoryBinding,
ApprovalsReviewer,
AgentPermissionRequestRecord,
CanonicalMemoryRecord,
FeishuRegion,
Expand Down Expand Up @@ -506,6 +507,7 @@ export const CreateAgentBody = z.object({
showStatusBar: z.boolean().optional(), // persistent Slack session status row (absent ⇒ default true)
fastMode: z.boolean().optional(), // runtime fast mode toggle
permissionMode: z.string().min(1).optional(), // runtime permission/approval mode
approvalsReviewer: ApprovalsReviewer.optional(), // who reviews eligible Codex approval requests
allowRuntimeChangesInChat: z.boolean().optional(), // explicit opt-in; absent ⇒ false
pause: z.boolean().optional(), // operational message-processing toggle (#288)
introduceOnJoin: z.boolean().optional(), // #536: self-introduce to peers on a genuine channel join
Expand Down Expand Up @@ -554,6 +556,7 @@ export const UpdateAgentBody = z
showStatusBar: z.boolean().optional(),
fastMode: z.boolean().nullable().optional(),
permissionMode: z.string().min(1).nullable().optional(),
approvalsReviewer: ApprovalsReviewer.nullable().optional(),
allowRuntimeChangesInChat: z.boolean().optional(),
pause: z.boolean().nullable().optional(), // operational toggle (#288); null clears
introduceOnJoin: z.boolean().optional(), // #536: self-introduce to peers on a genuine channel join
Expand Down Expand Up @@ -619,6 +622,7 @@ export const AgentDto = z.object({
showStatusBar: z.boolean(),
fastMode: z.boolean().nullable(), // null ⇒ never set (runtime default)
permissionMode: z.string().nullable(), // null ⇒ never set (runtime default)
approvalsReviewer: ApprovalsReviewer.nullable(), // null ⇒ runtime default (`user`)
allowRuntimeChangesInChat: z.boolean(),
pause: z.boolean().nullable(), // null ⇒ not paused (#288)
env: z.record(z.string(), z.string()),
Expand Down
3 changes: 3 additions & 0 deletions packages/control-plane/src/http/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
*/
import { randomUUID } from 'node:crypto'
import { z } from 'zod'
import { ApprovalsReviewer } from '@agentconnect.md/protocol'

/** What a tool needs to execute: the caller's org and credentialed requests
* against the versioned REST surface (`/api/v1`-relative paths). */
Expand Down Expand Up @@ -275,6 +276,7 @@ export const MCP_TOOLS: McpToolDef[] = [
outputMode: OutputMode.optional(),
fastMode: z.boolean().optional(),
permissionMode: z.string().min(1).optional(),
approvalsReviewer: ApprovalsReviewer.optional(),
daemonId: z.string().min(1).optional().describe('Pin to a daemon (from listDaemons); omit to leave unplaced'),
pause: z.boolean().optional()
})
Expand All @@ -297,6 +299,7 @@ export const MCP_TOOLS: McpToolDef[] = [
outputMode: OutputMode.nullable().optional(),
fastMode: z.boolean().nullable().optional(),
permissionMode: z.string().min(1).nullable().optional(),
approvalsReviewer: ApprovalsReviewer.nullable().optional(),
pause: z.boolean().optional().describe('true pauses the agent; false resumes it')
})
.strict(),
Expand Down
4 changes: 4 additions & 0 deletions packages/control-plane/src/http/routes/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ function toDto(
showStatusBar: a.showStatusBar,
fastMode: a.fastMode,
permissionMode: a.permissionMode,
approvalsReviewer: a.approvalsReviewer ?? null,
allowRuntimeChangesInChat: a.allowRuntimeChangesInChat,
pause: a.pause,
env: a.env,
Expand Down Expand Up @@ -1047,6 +1048,9 @@ export function agentRoutes(deps: HttpDeps) {
...(req.body.showStatusBar !== undefined ? { showStatusBar: req.body.showStatusBar } : {}),
...(req.body.fastMode !== undefined ? { fastMode: req.body.fastMode } : {}),
...(req.body.permissionMode !== undefined ? { permissionMode: req.body.permissionMode } : {}),
...(req.body.approvalsReviewer !== undefined
? { approvalsReviewer: req.body.approvalsReviewer }
: {}),
...(req.body.allowRuntimeChangesInChat !== undefined
? { allowRuntimeChangesInChat: req.body.allowRuntimeChangesInChat }
: {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ export function agentRecordToSpec(
model: a.model,
reasoningEffort: a.reasoningEffort,
permissionMode: a.permissionMode,
approvalsReviewer: a.approvalsReviewer ?? null,
showFooter: a.showFooter,
showStatusBar: a.showStatusBar,
allowRuntimeChangesInChat: a.allowRuntimeChangesInChat,
Expand Down
4 changes: 4 additions & 0 deletions packages/control-plane/src/persistence/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
BindRule,
AgentIcon,
AgentMemoryBinding,
ApprovalsReviewer,
OrganizationSuggestionInfo
} from '@agentconnect.md/protocol'
import type {
Expand Down Expand Up @@ -521,6 +522,7 @@ export interface CreateAgentInput {
showStatusBar?: boolean // render Slack's persistent session status row (default true)
fastMode?: boolean // runtime fast mode toggle
permissionMode?: string // runtime permission/approval mode
approvalsReviewer?: ApprovalsReviewer // who reviews eligible Codex approval requests
allowRuntimeChangesInChat?: boolean // explicit opt-in; default false
pause?: boolean // operational message-processing toggle (#288); true ⇒ daemon skips all turns
introduceOnJoin?: boolean // #536: self-introduce to peers on a genuine channel join (absent ⇒ DB default false)
Expand Down Expand Up @@ -570,6 +572,7 @@ export interface UpdateAgentInput {
showStatusBar?: boolean
fastMode?: boolean | null
permissionMode?: string | null
approvalsReviewer?: ApprovalsReviewer | null
allowRuntimeChangesInChat?: boolean
pause?: boolean | null // operational message-processing toggle (#288); null clears
introduceOnJoin?: boolean // #536: self-introduce to peers on a genuine channel join
Expand Down Expand Up @@ -618,6 +621,7 @@ export interface AgentRecord {
showStatusBar: boolean // from runtimeOverrides.showStatusBar (default true)
fastMode: boolean | null // from runtimeOverrides.fastMode (null ⇒ runtime default)
permissionMode: string | null // from runtimeOverrides.permissionMode (null ⇒ runtime default)
approvalsReviewer: ApprovalsReviewer | null // from runtimeOverrides.approvalsReviewer
allowRuntimeChangesInChat: boolean // from runtimeOverrides (default false)
pause: boolean | null // from runtimeOverrides.pause (null ⇒ not paused) (#288)
env: Record<string, string> // from runtimeOverrides.env ({} when unset)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/
import { Prisma } from '../../generated/prisma/client.js'
import type { Agent, PrismaClient, User } from '../../generated/prisma/client.js'
import { redactGitUrlSecrets, type AgentMemoryBinding } from '@agentconnect.md/protocol'
import { redactGitUrlSecrets, type AgentMemoryBinding, type ApprovalsReviewer } from '@agentconnect.md/protocol'
import type { PrismaLike } from '../prisma.js'
import type {
AgentCallPolicy,
Expand Down Expand Up @@ -130,6 +130,7 @@ type RuntimeOverrides = {
showStatusBar?: boolean
fastMode?: boolean
permissionMode?: string
approvalsReviewer?: ApprovalsReviewer
allowRuntimeChangesInChat?: boolean
// Operational message-processing toggle (#288). Stored in the overrides bag for
// consistency with the sibling boolean toggles; the daemon skips all turn dispatch
Expand Down Expand Up @@ -225,6 +226,7 @@ function toRecord(a: AgentWithUsers): AgentRecord {
showStatusBar: ov.showStatusBar ?? true,
fastMode: ov.fastMode ?? null,
permissionMode: ov.permissionMode ?? null,
approvalsReviewer: ov.approvalsReviewer ?? null,
allowRuntimeChangesInChat: ov.allowRuntimeChangesInChat ?? false,
pause: ov.pause ?? null,
env: ov.env ?? {},
Expand Down Expand Up @@ -318,6 +320,7 @@ export class PgAgentRepo implements AgentRepo {
input.showStatusBar !== undefined ||
input.fastMode !== undefined ||
input.permissionMode ||
input.approvalsReviewer ||
input.allowRuntimeChangesInChat !== undefined ||
input.pause !== undefined ||
input.env ||
Expand All @@ -333,6 +336,7 @@ export class PgAgentRepo implements AgentRepo {
...(input.showStatusBar !== undefined ? { showStatusBar: input.showStatusBar } : {}),
...(input.fastMode !== undefined ? { fastMode: input.fastMode } : {}),
...(input.permissionMode ? { permissionMode: input.permissionMode } : {}),
...(input.approvalsReviewer ? { approvalsReviewer: input.approvalsReviewer } : {}),
...(input.allowRuntimeChangesInChat !== undefined
? { allowRuntimeChangesInChat: input.allowRuntimeChangesInChat }
: {}),
Expand Down Expand Up @@ -411,6 +415,7 @@ export class PgAgentRepo implements AgentRepo {
patch.showStatusBar !== undefined ||
patch.fastMode !== undefined ||
patch.permissionMode !== undefined ||
patch.approvalsReviewer !== undefined ||
patch.allowRuntimeChangesInChat !== undefined ||
patch.pause !== undefined ||
patch.env !== undefined ||
Expand Down Expand Up @@ -473,6 +478,10 @@ export class PgAgentRepo implements AgentRepo {
if (patch.permissionMode === null) delete next.permissionMode
else next.permissionMode = patch.permissionMode
}
if (patch.approvalsReviewer !== undefined) {
if (patch.approvalsReviewer === null) delete next.approvalsReviewer
else next.approvalsReviewer = patch.approvalsReviewer
}
if (patch.allowRuntimeChangesInChat !== undefined) {
next.allowRuntimeChangesInChat = patch.allowRuntimeChangesInChat
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ describe('agent config replication CP→daemon (REST → agent/upsert·remove)',
// Always shipped as null when unset (per-runtime override): a runtime switch
// must be able to CLEAR it, so the spec carries the clear rather than omitting it.
permissionMode: null,
approvalsReviewer: null,
outputMode: 'medium',
showFooter: true,
showStatusBar: true,
Expand Down
14 changes: 11 additions & 3 deletions packages/control-plane/test/integration/agents.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -987,18 +987,19 @@ describe('C2 BFF REST — agents/daemons/workspaces/crons over app.inject', () =
expect(badKey.statusCode).toBe(400)
})

it('POST + PATCH /agents carries output and chat runtime controls in runtimeOverrides', async () => {
it('POST + PATCH /agents carries output, reviewer, and chat runtime controls in runtimeOverrides', async () => {
const app = build()
const create = await app.app.inject({
method: 'POST',
url: `${ORG}/agents`,
payload: {
name: 'verbose',
runtime: 'claude',
runtime: 'codex',
outputMode: 'high',
showFooter: false,
showStatusBar: false,
fastMode: true,
approvalsReviewer: 'auto_review',
allowRuntimeChangesInChat: true
}
})
Expand All @@ -1009,18 +1010,21 @@ describe('C2 BFF REST — agents/daemons/workspaces/crons over app.inject', () =
showFooter: boolean
showStatusBar: boolean
fastMode: boolean | null
approvalsReviewer: 'user' | 'auto_review' | null
allowRuntimeChangesInChat: boolean
}
expect(created.outputMode).toBe('high')
expect(created.showFooter).toBe(false)
expect(created.showStatusBar).toBe(false)
expect(created.fastMode).toBe(true)
expect(created.approvalsReviewer).toBe('auto_review')
expect(created.allowRuntimeChangesInChat).toBe(true)
expect((await prisma.agent.findUnique({ where: { id: created.id } }))?.runtimeOverrides).toEqual({
outputMode: 'high',
showFooter: false,
showStatusBar: false,
fastMode: true,
approvalsReviewer: 'auto_review',
allowRuntimeChangesInChat: true
})

Expand All @@ -1033,6 +1037,7 @@ describe('C2 BFF REST — agents/daemons/workspaces/crons over app.inject', () =
showFooter: true,
showStatusBar: true,
fastMode: false,
approvalsReviewer: 'user',
allowRuntimeChangesInChat: false
}
})
Expand All @@ -1042,21 +1047,24 @@ describe('C2 BFF REST — agents/daemons/workspaces/crons over app.inject', () =
showFooter: boolean
showStatusBar: boolean
fastMode: boolean | null
approvalsReviewer: 'user' | 'auto_review' | null
allowRuntimeChangesInChat: boolean
}
expect(patched.outputMode).toBe('low')
expect(patched.showFooter).toBe(true)
expect(patched.showStatusBar).toBe(true)
expect(patched.fastMode).toBe(false)
expect(patched.approvalsReviewer).toBe('user')
expect(patched.allowRuntimeChangesInChat).toBe(false)

const cleared = await app.app.inject({
method: 'PATCH',
url: `${ORG}/agents/${created.id}`,
payload: { outputMode: null, fastMode: null }
payload: { outputMode: null, fastMode: null, approvalsReviewer: null }
})
expect((cleared.json() as { outputMode: string | null }).outputMode).toBeNull()
expect((cleared.json() as { fastMode: boolean | null }).fastMode).toBeNull()
expect((cleared.json() as { approvalsReviewer: string | null }).approvalsReviewer).toBeNull()
expect((await prisma.agent.findUnique({ where: { id: created.id } }))?.runtimeOverrides).toEqual({
showFooter: true,
showStatusBar: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ describe('register handler — authoritative reconcile snapshot + idempotency +
model: null,
reasoningEffort: null,
permissionMode: null,
approvalsReviewer: null,
showFooter: true,
showStatusBar: true,
allowRuntimeChangesInChat: false,
Expand Down
20 changes: 12 additions & 8 deletions packages/daemon/src/acp/acp-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { accountAppIsolation } from './account-apps.js'
export type { SessionConfigOption, SessionConfigSelectGroup, SessionConfigSelectOption } from '@agentclientprotocol/sdk'

const PROTOCOL_VERSION = 1
export const APPROVALS_REVIEWER_CATEGORY = '_approvals_reviewer'

/** A session/load may replay the historical conversation stream. Keep that off
* platform transports, but preserve latest-wins metadata needed to converge the
Expand Down Expand Up @@ -135,6 +136,9 @@ export interface SessionConfigPrefs {
* `category: "mode"`. Values are runtime-owned (`default` / `plan` on
* claude-acp, `agent` / `read-only` on codex-acp, etc.). */
permissionMode?: string
/** Who reviews eligible approval requests, matched against codex-acp's
* `_approvals_reviewer` select. Independent from permissionMode. */
approvalsReviewer?: 'user' | 'auto_review'
/** Optional host-level system-prompt seed, layered ahead of any per-session append
* on Claude runtimes (see {@link claudeSessionMeta}). Left unset by default: the
* agent's identity + description now travel per-session in the agent meta object
Expand Down Expand Up @@ -826,13 +830,12 @@ export class AcpHost {
}

/**
* Apply the desired model / reasoning effort / fast mode to a fresh session
* via ACP `session/set_config_option`. Model first — the effort and fast-mode
* vocabularies depend on the selected model, and each response returns the
* reconciled option set the next step plans against. Best-effort by design: a
* runtime without the selector, an unoffered value, or a failed request logs
* and moves on — the session still runs on the runtime's defaults. Returns the
* final option set.
* Apply the desired session preferences to a fresh or restored session via ACP
* `session/set_config_option`. Model first — the effort and fast-mode vocabularies
* depend on the selected model, and each response returns the reconciled option set
* the next step plans against. Best-effort by design: a runtime without the selector,
* an unoffered value, or a failed request logs and moves on — the session still runs
* on the runtime's defaults. Returns the final option set.
*/
private async applySessionConfig(
sessionId: string,
Expand All @@ -848,6 +851,7 @@ export class AcpHost {
const prefs: Array<[category: string, desired: string | undefined]> = [
['model', this.opts.configPrefs?.model],
['mode', this.opts.configPrefs?.permissionMode],
[APPROVALS_REVIEWER_CATEGORY, this.opts.configPrefs?.approvalsReviewer],
['thought_level', ultracode ? undefined : this.opts.configPrefs?.reasoningEffort],
// Fast mode comes AFTER model: the option is only advertised (and the
// reconciled option set only carries it) once a fast-capable model is set.
Expand Down Expand Up @@ -875,7 +879,7 @@ export class AcpHost {
return options
}

/** Re-derive the model / effort / fast selector caches from a reconciled option set. */
/** Re-derive the model / effort / mode / fast selector caches from a reconciled option set. */
private refreshOptionCaches(configOptions: SessionConfigOption[] | null | undefined): void {
this.lastModelOptions = modelOptionsFrom(configOptions)
this.lastEffortOptions = effortOptionsFrom(configOptions, this.isClaudeRuntime())
Expand Down
4 changes: 4 additions & 0 deletions packages/daemon/src/agents/agent-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,10 @@ export const AgentSchema = z.object({
// runtime-owned strings: claude-acp uses default/acceptEdits/auto/dontAsk/plan,
// codex-acp uses read-only/agent/agent-full-access.
permissionMode: z.string().default('default'),
// Who reviews eligible Codex approval requests. This stays independent from
// permissionMode: Auto-review does not widen the active sandbox or policy.
// Absent leaves the runtime's own default (`user`) untouched.
approvalsReviewer: z.enum(['user', 'auto_review']).optional(),
// Conversation participants are not authorization principals by default.
// Editors may explicitly opt this agent back into chat-side runtime setting
// changes (model, effort, permission mode, fast mode) and approval controls.
Expand Down
6 changes: 5 additions & 1 deletion packages/daemon/src/agents/write-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,7 @@ function applySpecFields(
// runtime is CP-owned — a PATCH may switch it (e.g. claude → codex). Apply it on
// merge too, not only on create; absent ⇒ leave the on-disk runtime as-is.
if (spec.runtime !== undefined) raw.runtime = spec.runtime
// model/reasoningEffort/permissionMode are per-runtime override vocabularies:
// model/reasoningEffort/permissionMode/approvalsReviewer are per-runtime override vocabularies:
// null ⇒ clear (revert to runtime default), a value ⇒ set, absent ⇒ leave alone.
// Clearing must delete the key so a runtime switch drops the old runtime's override
// instead of leaving it stale (model handled below, inside runtimeOverrides).
Expand All @@ -747,6 +747,10 @@ function applySpecFields(
if (spec.permissionMode === null) delete raw.permissionMode
else raw.permissionMode = spec.permissionMode
}
if (spec.approvalsReviewer !== undefined) {
if (spec.approvalsReviewer === null) delete raw.approvalsReviewer
else raw.approvalsReviewer = spec.approvalsReviewer
}
if (spec.allowRuntimeChangesInChat !== undefined) {
raw.allowRuntimeChangesInChat = spec.allowRuntimeChangesInChat
}
Expand Down
1 change: 1 addition & 0 deletions packages/daemon/src/cli/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export async function runChat(opts: RunChatOpts): Promise<void> {
configPrefs: {
model: agent.runtimeOverrides?.model,
permissionMode: agent.permissionMode,
approvalsReviewer: agent.approvalsReviewer,
reasoningEffort: agent.reasoningEffort,
fastMode: agent.fastMode
}
Expand Down
1 change: 1 addition & 0 deletions packages/daemon/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4246,6 +4246,7 @@ export class Daemon {
configPrefs: {
model: agent.runtimeOverrides?.model,
permissionMode: agent.permissionMode,
approvalsReviewer: agent.approvalsReviewer,
reasoningEffort: agent.reasoningEffort,
fastMode: agent.fastMode
},
Expand Down
Loading