feat(yolo): move permission auto-accept (Yolo) to the server (#561)

## Summary

Yolo (permission auto-accept) currently lives entirely in the UI. Each
browser keeps its own toggle in `localStorage` and auto-replies to
permission requests over a 4-hop path (`OpenCode → server SSE → UI →
server proxy → OpenCode`). The server — which already sits on the event
stream that carries every `permission.asked` — does none of the work.

This PR makes the **server authoritative**: it owns the toggle state,
resolves family-root inheritance, and performs the auto-reply in-process
via loopback using the same `"once"` semantics the UI used to send. The
UI becomes a pure view: it toggles via REST and mirrors state from a
`yolo.stateChanged` SSE event.

## Why

- **Correctness**: the server already consumes the instance SSE stream
(`InstanceEventBridge`); auto-accepting there is the natural choke point
instead of bouncing to the UI and back.
- **Multi-client**: previously each browser had independent
`localStorage` state and never synced. Toggles now broadcast to all
connected clients in real time.
- **Headless**: Yolo keeps auto-accepting even when no UI is connected
(useful for long autonomous runs).
- **Latency**: drops from 4 hops to a single in-process loopback call.

## What changed

**Server (new, authoritative)**
- `permissions/auto-accept-store.ts` — in-memory state keyed by family
root. Faithful port of `resolvePermissionAutoAcceptFamilyRoot`:
fork/`revert` sessions root at themselves; enabling any member enables
the whole family.
- `permissions/auto-accept-manager.ts` — subscribes to `instance.event`,
builds the session tree from `session.created/updated/deleted`
(`properties.info`), intercepts `permission.v2.asked` /
`permission.asked`, dedupes in-flight replies, emits `yolo.stateChanged`
/ `yolo.autoAccepted`, clears per-instance state on
`workspace.stopped/error`.
- `permissions/opencode-replier.ts` — default replier calling OpenCode
directly (`getInstancePort` + auth header), mirroring
`background-processes/manager.ts`.
- `server/routes/yolo.ts` — `GET/POST
/workspaces/:id/yolo/sessions/:sid[/toggle]`, following existing route
conventions.
- `api-types.ts` / `events/bus.ts` — `YoloStateResponse` + the two new
event types registered in `onEvent` so they flow over `/api/events`.

**UI (pure view)**
- `permission-auto-accept.ts` — removed `localStorage`, persistence, and
drain logic; now a runtime (non-persisted) projection.
`resolvePermissionAutoAcceptFamilyRoot` is **retained as a display aid**
so the badge still lights up for child/sub-sessions of an enabled family
(preserving the inheritance UX).
- `instances.ts` — toggle calls REST (optimistic, reconciled on success,
reverted on failure); subscribes to `yolo.stateChanged`;
`ensureYoloStateSynced` backfills the active session's state on first
connect (deduped per session, reset on SSE reconnect so it re-syncs
after a server restart).
- `session-events.ts` — removed the three
`drainAutoAcceptPermissionsForInstance` hooks (the server drains now).
- `api-client.ts` — `getYoloState` / `toggleYolo`.

## Behavior parity

| Aspect | Before | After |
|---|---|---|
| Reply semantics | `"once"` | `"once"` (unchanged) |
| Inheritance | whole family (root + non-fork descendants) | **same** |
| Fork isolation | `revert` session is its own root | **same** |
| Persistence | UI `localStorage` | none (intentional, see Notes) |
| Multi-client sync |  independent per browser |  real-time via SSE |
| Works with UI closed |  |  |
| Auto-reply path | 4 hops | 1 in-process hop |

## Testing

32 unit tests added (`node:test`), all passing. Server + UI typecheck
clean.

- **Store (16)**: inheritance (parent/child/sibling), fork isolation,
cyclic parent chains, late parent discovery, `revert` re-rooting,
per-instance independence, tree maintenance.
- **Manager (13)**: real `properties.info` event shapes, v2 vs legacy
reply, in-flight dedup + retry-after-resolve, `yolo.stateChanged`
emission, `session.deleted` keeps toggle, `workspace.stopped` clears
state, `stop()` unsubscribes.
- **UI (3)**: retained `resolvePermissionAutoAcceptFamilyRoot`
display-projection tests.

## Notes for reviewers

- **No persistence is intentional** for this milestone — server restart
resets all Yolo state (matches the agreed scope). The UI mirror
self-heals via SSE reconnect + `ensureYoloStateSynced`. Persistence can
be layered on later (e.g. into `~/.config/codenomad/config.json`)
without touching the manager.
- **Family-root resolution lives in two places on purpose**: the server
resolves it to decide whether to auto-reply; the UI resolves the same
pure function to render the badge instantly (synchronous memo). Both are
faithful ports; no network round-trip is added for display.
- **`properties.info` nesting**: OpenCode wraps session records under
`properties.info` for `session.*` events (permission events are flat).
The manager handles both and the tests use the real nested shape to
guard against regressions.
- `getYoloState` / `yolo.autoAccepted` are wired but `yolo.autoAccepted`
is not yet consumed by the UI — it's available on the wire for future
observability (e.g. an audit log / toast).

## Risk / rollback

The change is additive on the server and the UI gracefully degrades to
the SSE mirror. If the server lacks the new routes (mixed-version), the
UI's optimistic toggle still flips locally and `toggleYolo` failures are
logged + reverted, so no hard breakage.

---------

Co-authored-by: Pascal André <pascalandr@gmail.com>
This commit is contained in:
heunghingwan 2026-07-09 03:13:44 +08:00 committed by GitHub
parent bce975d940
commit ca06bd99d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1583 additions and 146 deletions

View file

@ -7,11 +7,12 @@ End-to-end feature flows with decision branches and mechanism references.
1. **Server:** Backend emits SSE event `permission.asked` or `permission.updated`
- Events are pushed through the instance event stream
2. **UI Store:** `packages/ui/src/stores/instances.ts` receives via `serverEvents` handler
- **Branch:** IF `isPermissionAutoAcceptEnabled(instanceId, sessionId)` is true
- **Mechanism:** `drainAutoAcceptPermissions()` in `packages/ui/src/stores/permission-auto-accept.ts`
- **Action:** Automatically calls `Permission.reply()`, skips modal display
- **File:** `packages/ui/src/stores/permission-auto-accept.ts:drainAutoAcceptPermission()`
2. **Server AutoAcceptManager** intercepts permission events (if Yolo is enabled)
- **File:** `packages/server/src/permissions/auto-accept-manager.ts`
- **Action:** Auto-replies via SDK client, emits `yolo.autoAccepted` to UI for immediate queue cleanup
- **Pending drain:** Re-drains pending permissions on toggle(enable) and session ancestry changes
3. **UI Store:** `packages/ui/src/stores/instances.ts` receives via `serverEvents`
- **Branch:** IF `yolo.autoAccepted` event arrives → marks replied + removes from queue immediately
- **Branch:** ELSE (normal flow)
- **Mechanism:** Permission queued in `permissionQueues` signal
- **Action:** Display approval modal

View file

@ -129,20 +129,20 @@ Rapid successive operations can cause temporary desync:
1. **Server emits** `permission.asked` or `permission.updated` SSE event
- Pushed through instance event stream
2. **UI Store receives** via `serverEvents`
2. **Server AutoAcceptManager** intercepts the event (if Yolo is enabled)
- File: `packages/server/src/permissions/auto-accept-manager.ts`
- Action: Auto-replies via SDK client (`createInstanceClient`), tracks pending permissions, drains on enable/ancestry change
- Emits `yolo.autoAccepted` + `yolo.stateChanged` events to UI
3. **UI Store receives** via `serverEvents`
- File: `packages/ui/src/stores/instances.ts`
- **Branch:** IF `isPermissionAutoAcceptEnabled()`
- Mechanism: `drainAutoAcceptPermissions()` in `packages/ui/src/stores/permission-auto-accept.ts`
- Action: Calls reply immediately, skips modal
- **Branch:** ELSE
- Mechanism: Queued in `permissionQueues`
- Action: Display modal
3. **UI Store:** `packages/ui/src/stores/message-v2/bridge.ts` calls `upsertPermissionV2()`
4. **UI Component:** `packages/ui/src/components/permission-approval-modal.tsx` displays
5. **User Action:** Calls `packages/ui/src/stores/instances.ts:sendPermissionResponse()`
6. **SDK Call:** `client.permission.reply()` via `packages/ui/src/lib/opencode-api.ts`
7. **Optimistic Update:** `removePermissionV2()` in bridge
8. **SSE Confirmation:** `permission.replied` event
- **Branch:** IF `yolo.autoAccepted` event arrives → immediately marks replied + removes from queue
- **Branch:** ELSE (user must reply) → Queued in `permissionQueues` → Display modal
4. **UI Store:** `packages/ui/src/stores/message-v2/bridge.ts` calls `upsertPermissionV2()`
5. **UI Component:** `packages/ui/src/components/permission-approval-modal.tsx` displays
6. **User Action:** Calls `packages/ui/src/stores/instances.ts:sendPermissionResponse()`
7. **SDK Call:** `client.permission.reply()` via `packages/ui/src/lib/opencode-api.ts`
8. **Optimistic Update:** `removePermissionV2()` in bridge
9. **SSE Confirmation:** `permission.replied` event
- **Branch:** IF SSE disconnected → `syncPendingPermissions()` reconciles on reconnect
## Session Event Handling

1
package-lock.json generated
View file

@ -13454,6 +13454,7 @@
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
"@fastify/static": "^7.0.4",
"@opencode-ai/sdk": "^1.17.8",
"commander": "^12.1.0",
"fastify": "^4.28.1",
"fuzzysort": "^2.0.4",

View file

@ -28,6 +28,7 @@
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
"@fastify/static": "^7.0.4",
"@opencode-ai/sdk": "^1.17.8",
"commander": "^12.1.0",
"fastify": "^4.28.1",
"fuzzysort": "^2.0.4",

View file

@ -365,6 +365,10 @@ export interface VoiceModeStateResponse {
enabled: boolean
}
export interface YoloStateResponse {
enabled: boolean
}
export interface RemoteServerProfile {
id: string
name: string
@ -414,6 +418,8 @@ export type WorkspaceEventType =
| "instance.dataChanged"
| "instance.event"
| "instance.eventStatus"
| "yolo.stateChanged"
| "yolo.autoAccepted"
export type WorkspaceEventPayload =
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
@ -428,6 +434,8 @@ export type WorkspaceEventPayload =
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
| { type: "yolo.stateChanged"; instanceId: string; sessionId: string; enabled: boolean }
| { type: "yolo.autoAccepted"; instanceId: string; sessionId: string; permissionId: string }
export interface NetworkAddress {
ip: string

View file

@ -31,6 +31,8 @@ export class EventBus extends EventEmitter {
this.on("instance.dataChanged", handler)
this.on("instance.event", handler)
this.on("instance.eventStatus", handler)
this.on("yolo.stateChanged", handler)
this.on("yolo.autoAccepted", handler)
return () => {
this.off("workspace.created", handler)
this.off("workspace.started", handler)
@ -44,6 +46,8 @@ export class EventBus extends EventEmitter {
this.off("instance.dataChanged", handler)
this.off("instance.event", handler)
this.off("instance.eventStatus", handler)
this.off("yolo.stateChanged", handler)
this.off("yolo.autoAccepted", handler)
}
}
}

View file

@ -0,0 +1,668 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { EventBus } from "../events/bus"
import { AutoAcceptManager, type PermissionReplier, type AutoAcceptReply } from "./auto-accept-manager"
import type { InstanceStreamEvent } from "../api-types"
import type { Logger } from "../logger"
const noopLogger: Logger = {
debug() {},
info() {},
warn() {},
error() {},
trace() {},
isLevelEnabled() {
return false
},
child() {
return noopLogger
},
} as unknown as Logger
function publishInstanceEvent(bus: EventBus, instanceId: string, event: Record<string, unknown>) {
bus.publish({ type: "instance.event", instanceId, event: { ...event } as InstanceStreamEvent })
}
/** Publish a `session.*` event using the real OpenCode shape (`properties.info`). */
function publishSession(
bus: EventBus,
instanceId: string,
eventType: "session.updated" | "session.created" | "session.deleted",
info: Record<string, unknown>,
) {
publishInstanceEvent(bus, instanceId, { type: eventType, properties: { info: { ...info } } })
}
describe("AutoAcceptManager session tree", () => {
it("ingests session.updated to build the parent chain", () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" })
assert.equal(manager.isEnabled("inst", "master"), false)
manager.toggle("inst", "child")
assert.equal(manager.isEnabled("inst", "child"), true)
assert.equal(manager.isEnabled("inst", "master"), true)
manager.stop()
})
it("treats a session with revert as a fork root", () => {
const bus = new EventBus(noopLogger)
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
publishSession(bus, "inst", "session.updated", {
id: "fork",
parentID: "master",
revert: { messageID: "m", partID: "p" },
})
manager.toggle("inst", "fork")
assert.equal(manager.isEnabled("inst", "fork"), true)
assert.equal(manager.isEnabled("inst", "master"), false)
manager.stop()
})
it("session.deleted removes the tree entry but keeps the toggle", () => {
const bus = new EventBus(noopLogger)
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
manager.toggle("inst", "master")
publishSession(bus, "inst", "session.deleted", { id: "master" })
// toggle is independent of the tree (survives deletion)
assert.equal(manager.isEnabled("inst", "master"), true)
manager.stop()
})
})
describe("AutoAcceptManager permission interception", () => {
it("auto-replies to a v2 permission on an enabled family", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const accepted: Record<string, unknown>[] = []
bus.on("yolo.autoAccepted", (e) => accepted.push(e))
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" })
manager.toggle("inst", "child") // enable the whole family root
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-1", sessionID: "child", action: "edit", resources: ["a.ts"] },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 1)
const call = replier.calls[0]
assert.equal(call.instanceId, "inst")
assert.equal(call.permissionId, "perm-1")
assert.equal(call.sessionId, "child")
assert.equal(call.source, "v2")
assert.equal(call.reply, "once")
assert.equal(accepted.length, 1)
assert.equal((accepted[0] as any).permissionId, "perm-1")
manager.stop()
})
it("auto-replies to a legacy permission.asked event", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
publishInstanceEvent(bus, "inst", {
type: "permission.asked",
properties: { id: "perm-2", sessionID: "solo", type: "bash" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 1)
assert.equal(replier.calls[0].source, "legacy")
assert.equal(replier.calls[0].permissionId, "perm-2")
manager.stop()
})
it("does not reply when the family is disabled", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-3", sessionID: "solo" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
manager.stop()
})
it("ignores permission events without an id or sessionID", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
publishInstanceEvent(bus, "inst", { type: "permission.v2.asked", properties: { sessionID: "solo" } })
publishInstanceEvent(bus, "inst", { type: "permission.v2.asked", properties: { id: "x" } })
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
manager.stop()
})
it("deduplicates repeated emission of the same permission", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
for (let i = 0; i < 3; i++) {
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-dup", sessionID: "solo" },
})
}
await flushMicrotasks()
assert.equal(replier.calls.length, 1)
manager.stop()
})
it("clears in-flight tracking after the reply resolves so it can retry", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-retry", sessionID: "solo" },
})
await flushMicrotasks()
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-retry", sessionID: "solo" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 2)
manager.stop()
})
})
describe("AutoAcceptManager state events", () => {
it("publishes yolo.stateChanged with the new enabled value on toggle", () => {
const bus = new EventBus(noopLogger)
const changes: Record<string, unknown>[] = []
bus.on("yolo.stateChanged", (e) => changes.push(e))
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
manager.toggle("inst", "master")
manager.toggle("inst", "master")
assert.equal(changes.length, 2)
assert.equal((changes[0] as any).enabled, true)
assert.equal((changes[1] as any).enabled, false)
assert.equal((changes[0] as any).sessionId, "master")
assert.equal((changes[0] as any).instanceId, "inst")
manager.stop()
})
})
describe("AutoAcceptManager lifecycle", () => {
it("clearInstance drops tree and enabled state for the instance", () => {
const bus = new EventBus(noopLogger)
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
manager.toggle("inst", "master")
manager.clearInstance("inst")
assert.equal(manager.isEnabled("inst", "master"), false)
manager.stop()
})
it("clears state when the workspace stops", () => {
const bus = new EventBus(noopLogger)
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
manager.toggle("inst", "master")
bus.publish({ type: "workspace.stopped", workspaceId: "inst" })
assert.equal(manager.isEnabled("inst", "master"), false)
manager.stop()
})
it("stop() unsubscribes so no further events are processed", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
manager.stop()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "p", sessionID: "solo" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
})
})
describe("AutoAcceptManager pending permissions drain", () => {
it("drains a pending permission that arrived before enable", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
// permission arrives while yolo is OFF
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-pending", sessionID: "solo" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
// enabling yolo should drain the pending permission
manager.toggle("inst", "solo")
await flushMicrotasks()
assert.equal(replier.calls.length, 1)
assert.equal(replier.calls[0].permissionId, "perm-pending")
manager.stop()
})
it("drains pending permissions for the same family only", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "root-a", parentID: null })
publishSession(bus, "inst", "session.updated", { id: "root-b", parentID: null })
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-a", sessionID: "root-a" },
})
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-b", sessionID: "root-b" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
manager.toggle("inst", "root-a")
await flushMicrotasks()
assert.equal(replier.calls.length, 1)
assert.equal(replier.calls[0].permissionId, "perm-a")
manager.stop()
})
it("does not re-drain already-auto-accepted permissions", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-1", sessionID: "solo" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 1)
// toggling off then on should not re-drain the already-replied permission
manager.toggle("inst", "solo") // off
manager.toggle("inst", "solo") // on — drain runs but pending set is empty
await flushMicrotasks()
assert.equal(replier.calls.length, 1)
manager.stop()
})
it("re-drains pending when late session ancestry joins an enabled family", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
// master exists, yolo enabled on master
publishSession(bus, "inst", "session.updated", { id: "master", parentID: null })
manager.toggle("inst", "master")
// child permission arrives BEFORE child's session ancestry is known.
// At this point "child" is unknown to the tree, so it resolves as its
// own root and the permission is NOT auto-accepted.
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-late", sessionID: "child" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
// child session ancestry arrives — child.parentID = "master".
// ingestSession → upsertSession → migrateEnabledRoots makes "child"
// resolve to "master" (enabled). drainPending should fire and accept
// the previously-pending permission.
publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" })
await flushMicrotasks()
assert.equal(replier.calls.length, 1)
assert.equal(replier.calls[0].permissionId, "perm-late")
assert.equal(replier.calls[0].sessionId, "child")
manager.stop()
})
})
describe("AutoAcceptManager permission replied cleanup", () => {
it("removes a pending permission on permission.v2.replied", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-x", sessionID: "solo" },
})
await flushMicrotasks()
// user manually replies → replied event cleans up pending
publishInstanceEvent(bus, "inst", {
type: "permission.v2.replied",
properties: { id: "perm-x" },
})
// enabling yolo now should NOT drain the already-replied permission
manager.toggle("inst", "solo")
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
manager.stop()
})
it("removes a pending permission on legacy permission.replied", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
publishInstanceEvent(bus, "inst", {
type: "permission.asked",
properties: { id: "perm-y", sessionID: "solo" },
})
await flushMicrotasks()
publishInstanceEvent(bus, "inst", {
type: "permission.replied",
properties: { requestID: "perm-y" },
})
manager.toggle("inst", "solo")
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
manager.stop()
})
})
describe("AutoAcceptManager clearInstance clears pending", () => {
it("drops pending permissions on clearInstance", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-z", sessionID: "solo" },
})
await flushMicrotasks()
manager.clearInstance("inst")
// re-create session and enable — pending set should be empty
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
manager.stop()
})
})
describe("AutoAcceptManager permission.updated source inference", () => {
it("preserves the original v2 source when permission.updated arrives", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
// yolo is OFF — permission goes to pending with source "v2"
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-v2", sessionID: "solo" },
})
await flushMicrotasks()
// enable yolo, then send permission.updated — should keep source "v2"
manager.toggle("inst", "solo")
publishInstanceEvent(bus, "inst", {
type: "permission.updated",
properties: { id: "perm-v2", sessionID: "solo" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 1)
assert.equal(replier.calls[0].source, "v2")
manager.stop()
})
it("skips permission.updated for a permission not in pending", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
// permission.updated for a permission that was never asked (not in pending)
publishInstanceEvent(bus, "inst", {
type: "permission.updated",
properties: { id: "perm-unknown", sessionID: "solo" },
})
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
manager.stop()
})
})
describe("AutoAcceptManager replier failure handling", () => {
it("keeps permission pending and does not emit autoAccepted on failure", async () => {
const bus = new EventBus(noopLogger)
const autoAccepted: unknown[] = []
bus.on("yolo.autoAccepted", (e) => autoAccepted.push(e))
const failingReplier: PermissionReplier = async () => {
throw new Error("connection refused")
}
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: failingReplier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-fail", sessionID: "solo" },
})
await flushMicrotasks()
assert.equal(autoAccepted.length, 0)
// permission should still be pending (enabling again would try again)
manager.toggle("inst", "solo") // off
manager.toggle("inst", "solo") // on — drain retries
await flushMicrotasks()
assert.equal(autoAccepted.length, 0)
manager.stop()
})
it("stops retrying after MAX_REPLY_ATTEMPTS", async () => {
const bus = new EventBus(noopLogger)
let callCount = 0
const failingReplier: PermissionReplier = async () => {
callCount++
throw new Error("timeout")
}
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: failingReplier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-stuck", sessionID: "solo" },
})
await flushMicrotasks()
const attemptsAfterFirst = callCount
// trigger drains via session events
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
await flushMicrotasks()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
await flushMicrotasks()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
await flushMicrotasks()
// should have attempted at most MAX_REPLY_ATTEMPTS times total
assert.ok(callCount <= 3, `expected at most 3 attempts, got ${callCount}`)
assert.equal(callCount, attemptsAfterFirst + 2) // 2 more retries (total 3), then stops
manager.stop()
})
})
describe("AutoAcceptManager workspace.error cleanup", () => {
it("clears state when the workspace errors", () => {
const bus = new EventBus(noopLogger)
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
manager.toggle("inst", "solo")
assert.equal(manager.isEnabled("inst", "solo"), true)
bus.publish({ type: "workspace.error", workspace: { id: "inst" } as any })
assert.equal(manager.isEnabled("inst", "solo"), false)
manager.stop()
})
})
describe("AutoAcceptManager session.deleted clears pending", () => {
it("removes pending permissions for a deleted session", async () => {
const bus = new EventBus(noopLogger)
const replier = makeRecordingReplier()
const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier })
manager.start()
publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null })
// permission arrives while yolo is OFF → goes to pending
publishInstanceEvent(bus, "inst", {
type: "permission.v2.asked",
properties: { id: "perm-doomed", sessionID: "solo" },
})
await flushMicrotasks()
// session deleted → pending should be cleared
publishSession(bus, "inst", "session.deleted", { id: "solo" })
// enabling yolo should not drain the deleted session's permission
manager.toggle("inst", "solo")
await flushMicrotasks()
assert.equal(replier.calls.length, 0)
manager.stop()
})
})
function makeRecordingReplier() {
const calls: AutoAcceptReply[] = []
const replier: PermissionReplier = async (reply) => {
calls.push(reply)
}
return Object.assign(replier, { calls }) as PermissionReplier & { calls: AutoAcceptReply[] }
}
function flushMicrotasks() {
return new Promise<void>((resolve) => setImmediate(resolve))
}

View file

@ -0,0 +1,296 @@
import type { EventBus } from "../events/bus"
import type { Logger } from "../logger"
import { AutoAcceptStore } from "./auto-accept-store"
/**
* Server-side owner of Yolo (permission auto-accept).
*
* Subscribes to the instance SSE stream that the server already consumes
* (`InstanceEventBridge` -> EventBus `instance.event`) and:
* - maintains a per-instance session tree so family-root inheritance can
* be resolved identically to the previous frontend implementation
* - when a permission request arrives for an enabled family, auto-replies
* via the injected {@link PermissionReplier} (same `"once"` semantics the
* UI used to send)
* - emits `yolo.stateChanged` / `yolo.autoAccepted` events on the EventBus
* so the UI stays a pure view
*/
export type PermissionSource = "v2" | "legacy"
export type PermissionReplyValue = "once"
export interface AutoAcceptReply {
instanceId: string
permissionId: string
sessionId: string
source: PermissionSource
reply: PermissionReplyValue
}
export type PermissionReplier = (reply: AutoAcceptReply) => Promise<void>
interface PendingPermission {
permissionId: string
sessionId: string
source: PermissionSource
}
interface AutoAcceptManagerDeps {
eventBus: EventBus
logger: Logger
replier: PermissionReplier
}
const PERMISSION_ASK_TYPES = new Set(["permission.v2.asked", "permission.asked", "permission.updated"])
const PERMISSION_REPLIED_TYPES = new Set(["permission.v2.replied", "permission.replied"])
const SESSION_UPSERT_TYPES = new Set(["session.updated", "session.created"])
const SESSION_REMOVE_TYPES = new Set(["session.deleted"])
export class AutoAcceptManager {
private static readonly MAX_REPLY_ATTEMPTS = 3
private readonly store = new AutoAcceptStore()
/** instanceId:permissionId entries currently being replied, to dedupe re-emissions */
private readonly inFlight = new Set<string>()
/** instanceId -> (permissionId -> pending permission) awaiting a reply */
private readonly pending = new Map<string, Map<string, PendingPermission>>()
/** instanceId:permissionId -> failure count, to stop retrying stuck permissions */
private readonly replyAttempts = new Map<string, number>()
private unsubscribe?: () => void
constructor(private readonly deps: AutoAcceptManagerDeps) {}
start(): void {
if (this.unsubscribe) return
const handler = (payload: { instanceId?: string; event?: InstanceStreamPayload }) => {
if (!payload || !payload.instanceId || !payload.event) return
this.handleInstanceEvent(payload.instanceId, payload.event)
}
const onStopped = (event: { workspaceId?: string }) => {
if (event?.workspaceId) this.clearInstance(event.workspaceId)
}
const onError = (event: { workspace?: { id?: string } }) => {
if (event?.workspace?.id) this.clearInstance(event.workspace.id)
}
this.deps.eventBus.on("instance.event", handler)
this.deps.eventBus.on("workspace.stopped", onStopped)
this.deps.eventBus.on("workspace.error", onError)
this.unsubscribe = () => {
this.deps.eventBus.off("instance.event", handler)
this.deps.eventBus.off("workspace.stopped", onStopped)
this.deps.eventBus.off("workspace.error", onError)
}
}
stop(): void {
this.unsubscribe?.()
this.unsubscribe = undefined
}
isEnabled(instanceId: string, sessionId: string): boolean {
return this.store.isEnabled(instanceId, sessionId)
}
toggle(instanceId: string, sessionId: string): boolean {
const enabled = this.store.toggle(instanceId, sessionId)
this.deps.eventBus.publish({ type: "yolo.stateChanged", instanceId, sessionId, enabled })
if (enabled) {
this.drainPending(instanceId, sessionId)
}
return enabled
}
clearInstance(instanceId: string): void {
this.store.clearInstance(instanceId)
this.pending.delete(instanceId)
const prefix = `${instanceId}:`
for (const key of Array.from(this.inFlight.keys())) {
if (key.startsWith(prefix)) this.inFlight.delete(key)
}
for (const key of Array.from(this.replyAttempts.keys())) {
if (key.startsWith(prefix)) this.replyAttempts.delete(key)
}
}
handleInstanceEvent(instanceId: string, event: InstanceStreamPayload): void {
if (!event || typeof event.type !== "string") return
if (SESSION_UPSERT_TYPES.has(event.type)) {
this.ingestSession(instanceId, event.properties)
return
}
if (SESSION_REMOVE_TYPES.has(event.type)) {
const info = (event.properties as { info?: SessionProperties } | undefined)?.info
const id = readString(info?.id) ?? readString(event.properties?.id)
if (id) {
this.store.removeSession(instanceId, id)
this.removePendingForSession(instanceId, id)
}
return
}
if (PERMISSION_REPLIED_TYPES.has(event.type)) {
this.handlePermissionReplied(instanceId, event.properties)
return
}
if (PERMISSION_ASK_TYPES.has(event.type)) {
this.handlePermissionRequest(instanceId, event.type, event.properties)
}
}
private ingestSession(instanceId: string, properties: unknown): void {
// OpenCode wraps session records under `properties.info` for
// session.created/updated/deleted (see SDK EventSessionUpdated). Accept a
// flat fallback only for defensive compatibility.
const info = (properties as { info?: SessionProperties } | SessionProperties | undefined)
const session = (info && typeof info === "object" && "info" in info ? info.info : info) as
| SessionProperties
| undefined
if (!session || typeof session.id !== "string") return
const parentId = session.parentID ?? session.parentId ?? null
const revert = session.revert ?? undefined
this.store.upsertSession(instanceId, { id: session.id, parentId, revert })
// Session ancestry may have changed (parent discovered, revert toggled).
// Re-drain pending permissions whose family root may have migrated into
// an enabled family — mirrors the old UI's drainAutoAcceptPermissions-
// ForInstance trigger on session.updated (#497).
this.drainPending(instanceId, session.id)
}
private handlePermissionRequest(instanceId: string, eventType: string, permission: unknown): void {
const request = permission as PermissionProperties | undefined
if (!request) return
const permissionId = readString(request.id)
const sessionId = readString(request.sessionID) ?? readString(request.sessionId)
if (!permissionId || !sessionId) return
// Infer source from the event type, but prefer the already-tracked source
// for permission.updated (which may belong to a v2 permission).
const existing = this.pending.get(instanceId)?.get(permissionId)
const source: PermissionSource = eventType === "permission.v2.asked" ? "v2" : (existing?.source ?? "legacy")
// `permission.updated` represents a detail change for a permission that
// is *already* pending. If it is no longer in our pending set it was
// already replied to (by us or the user) — skip to avoid a duplicate reply.
if (eventType === "permission.updated" && !this.pending.get(instanceId)?.has(permissionId)) {
return
}
this.addPending(instanceId, { permissionId, sessionId, source })
if (!this.store.isEnabled(instanceId, sessionId)) return
this.tryAutoAccept(instanceId, permissionId, sessionId, source)
}
private handlePermissionReplied(instanceId: string, properties: unknown): void {
const request = (properties as PermissionRepliedProperties | undefined) ?? {}
const permissionId =
readString(request.id) ??
readString(request.requestID) ??
readString(request.permissionID) ??
readString(request.requestId) ??
readString(request.permissionId)
if (permissionId) this.removePending(instanceId, permissionId)
}
private tryAutoAccept(
instanceId: string,
permissionId: string,
sessionId: string,
source: PermissionSource,
): void {
const key = `${instanceId}:${permissionId}`
if (this.inFlight.has(key)) return
const attempts = this.replyAttempts.get(key) ?? 0
if (attempts >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) return
this.inFlight.add(key)
this.replyAttempts.set(key, attempts + 1)
const reply: AutoAcceptReply = { instanceId, permissionId, sessionId, source, reply: "once" }
void this.deps.replier(reply)
.then(() => {
this.replyAttempts.delete(key)
this.removePending(instanceId, permissionId)
this.deps.eventBus.publish({ type: "yolo.autoAccepted", instanceId, sessionId, permissionId })
})
.catch((error) => {
this.deps.logger.error({ instanceId, permissionId, err: error, attempt: attempts + 1 }, "Yolo auto-accept reply failed")
if (attempts + 1 >= AutoAcceptManager.MAX_REPLY_ATTEMPTS) {
this.removePending(instanceId, permissionId)
}
})
.finally(() => {
this.inFlight.delete(key)
})
}
/** Auto-accept all pending permissions belonging to the same family root. */
private drainPending(instanceId: string, sessionId: string): void {
const instancePending = this.pending.get(instanceId)
if (!instancePending || instancePending.size === 0) return
const root = this.store.familyRoot(instanceId, sessionId)
for (const entry of Array.from(instancePending.values())) {
if (this.store.familyRoot(instanceId, entry.sessionId) === root) {
this.tryAutoAccept(instanceId, entry.permissionId, entry.sessionId, entry.source)
}
}
}
private addPending(instanceId: string, entry: PendingPermission): void {
let instancePending = this.pending.get(instanceId)
if (!instancePending) {
instancePending = new Map()
this.pending.set(instanceId, instancePending)
}
instancePending.set(entry.permissionId, entry)
}
private removePending(instanceId: string, permissionId: string): void {
const instancePending = this.pending.get(instanceId)
if (instancePending?.delete(permissionId)) {
this.replyAttempts.delete(`${instanceId}:${permissionId}`)
if (instancePending.size === 0) this.pending.delete(instanceId)
}
}
private removePendingForSession(instanceId: string, sessionId: string): void {
const instancePending = this.pending.get(instanceId)
if (!instancePending) return
for (const [permId, entry] of Array.from(instancePending)) {
if (entry.sessionId === sessionId) {
instancePending.delete(permId)
this.replyAttempts.delete(`${instanceId}:${permId}`)
}
}
if (instancePending.size === 0) this.pending.delete(instanceId)
}
}
interface InstanceStreamPayload {
type?: string
properties?: Record<string, unknown>
}
interface SessionProperties {
id?: string
parentID?: string | null
parentId?: string | null
revert?: unknown
}
interface PermissionProperties {
id?: string
sessionID?: string
sessionId?: string
}
interface PermissionRepliedProperties {
id?: string
requestID?: string
permissionID?: string
requestId?: string
permissionId?: string
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined
}

View file

@ -0,0 +1,182 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { AutoAcceptStore, resolveFamilyRoot } from "./auto-accept-store"
describe("resolveFamilyRoot", () => {
it("returns the session id itself when no info is known", () => {
assert.equal(resolveFamilyRoot("orphan", () => undefined), "orphan")
})
it("keeps a loaded child as root when its parent is missing", () => {
const root = resolveFamilyRoot("child", (id) =>
id === "child" ? { id: "child", parentId: "parent" } : undefined,
)
assert.equal(root, "child")
})
it("resolves to the master session when the full parent chain is loaded", () => {
const root = resolveFamilyRoot("grandchild", (id) => {
if (id === "grandchild") return { id: "grandchild", parentId: "child" }
if (id === "child") return { id: "child", parentId: "master" }
if (id === "master") return { id: "master", parentId: null }
return undefined
})
assert.equal(root, "master")
})
it("keeps a fork session (with revert) as its own root", () => {
const root = resolveFamilyRoot("fork", (id) => {
if (id === "fork")
return { id: "fork", parentId: "master", revert: { messageID: "msg", partID: "part" } }
if (id === "master") return { id: "master", parentId: null }
return undefined
})
assert.equal(root, "fork")
})
it("terminates on cyclic parent chains without looping forever", () => {
const root = resolveFamilyRoot("a", (id) => {
if (id === "a") return { id: "a", parentId: "b" }
if (id === "b") return { id: "b", parentId: "a" }
return undefined
})
// cycle: last known id before re-entering the cycle is returned
assert.ok(root === "a" || root === "b")
})
})
describe("AutoAcceptStore inheritance", () => {
it("is disabled by default for an unknown session", () => {
const store = new AutoAcceptStore()
assert.equal(store.isEnabled("inst", "s1"), false)
})
it("enabling a parent enables every descendant that resolves to it", () => {
const store = new AutoAcceptStore()
store.upsertSession("inst", { id: "master", parentId: null })
store.upsertSession("inst", { id: "child", parentId: "master" })
store.upsertSession("inst", { id: "grandchild", parentId: "child" })
store.setEnabled("inst", "master", true)
assert.equal(store.isEnabled("inst", "master"), true)
assert.equal(store.isEnabled("inst", "child"), true)
assert.equal(store.isEnabled("inst", "grandchild"), true)
})
it("enabling a child also covers the parent family root and siblings", () => {
const store = new AutoAcceptStore()
store.upsertSession("inst", { id: "master", parentId: null })
store.upsertSession("inst", { id: "child-a", parentId: "master" })
store.upsertSession("inst", { id: "child-b", parentId: "master" })
store.setEnabled("inst", "child-a", true)
assert.equal(store.isEnabled("inst", "child-a"), true)
assert.equal(store.isEnabled("inst", "child-b"), true)
assert.equal(store.isEnabled("inst", "master"), true)
})
it("a fork session is isolated: enabling it does not enable its parent", () => {
const store = new AutoAcceptStore()
store.upsertSession("inst", { id: "master", parentId: null })
store.upsertSession("inst", {
id: "fork",
parentId: "master",
revert: { messageID: "msg", partID: "part" },
})
store.setEnabled("inst", "fork", true)
assert.equal(store.isEnabled("inst", "fork"), true)
assert.equal(store.isEnabled("inst", "master"), false)
})
it("disabling the family root clears the setting for all descendants", () => {
const store = new AutoAcceptStore()
store.upsertSession("inst", { id: "master", parentId: null })
store.upsertSession("inst", { id: "child", parentId: "master" })
store.setEnabled("inst", "child", true)
assert.equal(store.isEnabled("inst", "child"), true)
store.setEnabled("inst", "master", false)
assert.equal(store.isEnabled("inst", "child"), false)
assert.equal(store.isEnabled("inst", "master"), false)
})
it("toggle flips the resolved family-root state and reports the new value", () => {
const store = new AutoAcceptStore()
store.upsertSession("inst", { id: "master", parentId: null })
store.upsertSession("inst", { id: "child", parentId: "master" })
assert.equal(store.toggle("inst", "child"), true)
assert.equal(store.isEnabled("inst", "child"), true)
assert.equal(store.toggle("inst", "master"), false)
assert.equal(store.isEnabled("inst", "child"), false)
})
it("keeps per-instance state independent", () => {
const store = new AutoAcceptStore()
store.upsertSession("inst-a", { id: "root", parentId: null })
store.upsertSession("inst-b", { id: "root", parentId: null })
store.setEnabled("inst-a", "root", true)
assert.equal(store.isEnabled("inst-a", "root"), true)
assert.equal(store.isEnabled("inst-b", "root"), false)
})
})
describe("AutoAcceptStore session tree maintenance", () => {
it("migrates enabled root when a parent is discovered later", () => {
const store = new AutoAcceptStore()
store.upsertSession("inst", { id: "child", parentId: "parent" })
// parent unknown -> child is its own root
store.setEnabled("inst", "child", true)
// later the parent shows up — root should migrate from "child" to "parent"
store.upsertSession("inst", { id: "parent", parentId: null })
assert.equal(store.isEnabled("inst", "parent"), true)
assert.equal(store.isEnabled("inst", "child"), true)
})
it("removing a session does not clear an enabled family root", () => {
const store = new AutoAcceptStore()
store.upsertSession("inst", { id: "master", parentId: null })
store.setEnabled("inst", "master", true)
store.removeSession("inst", "master")
// the toggle is independent of the session tree: it survives session deletion
assert.equal(store.isEnabled("inst", "master"), true)
})
it("clearInstance drops both tree and enabled state", () => {
const store = new AutoAcceptStore()
store.upsertSession("inst", { id: "master", parentId: null })
store.setEnabled("inst", "master", true)
store.clearInstance("inst")
assert.equal(store.isEnabled("inst", "master"), false)
store.upsertSession("inst", { id: "master", parentId: null })
assert.equal(store.isEnabled("inst", "master"), false)
})
it("changing revert status re-roots a session as a fork", () => {
const store = new AutoAcceptStore()
store.upsertSession("inst", { id: "master", parentId: null })
store.upsertSession("inst", { id: "child", parentId: "master" })
store.setEnabled("inst", "child", true)
// parent family enabled
assert.equal(store.isEnabled("inst", "master"), true)
// child becomes a fork
store.upsertSession("inst", {
id: "child",
parentId: "master",
revert: { messageID: "m", partID: "p" },
})
// now child resolves to itself; the family setting was on "master" so still on for master
assert.equal(store.isEnabled("inst", "master"), true)
// child is its own root now, not enabled unless toggled
assert.equal(store.isEnabled("inst", "child"), false)
})
})

View file

@ -0,0 +1,128 @@
/**
* In-memory permission auto-accept (Yolo) state, owned by the server.
*
* This is a faithful port of the previous frontend implementation
* (`packages/ui/src/stores/permission-auto-accept.ts`) so the inheritance
* semantics are preserved exactly:
* - state is keyed by the resolved *family root* session id
* - a session with a `revert` snapshot is treated as its own root (fork)
* - enabling any session enables its whole family root and vice-versa
*
* No persistence: state is lost on server restart, matching the "no
* persistence for now" milestone.
*/
export interface AutoAcceptSessionInfo {
id: string
parentId?: string | null
/** Truthy value marks the session as a fork that roots at itself. */
revert?: unknown
}
type SessionLookup = (sessionId: string) => AutoAcceptSessionInfo | undefined
/**
* Resolve the family-root session id for `sessionId` by walking the parent
* chain. Mirrors `resolvePermissionAutoAcceptFamilyRoot` from the UI so
* inheritance behaviour does not change.
*/
export function resolveFamilyRoot(sessionId: string, getSession: SessionLookup): string {
let currentId = sessionId
let lastKnownId = sessionId
const seen = new Set<string>()
while (currentId && !seen.has(currentId)) {
seen.add(currentId)
const session = getSession(currentId)
if (!session) return lastKnownId
lastKnownId = session.id
if (session.revert) return session.id
if (!session.parentId) return session.id
currentId = session.parentId
}
return currentId || sessionId
}
export class AutoAcceptStore {
/** instanceId -> set of enabled family-root session ids */
private readonly enabled = new Map<string, Set<string>>()
/** instanceId -> (sessionId -> info) */
private readonly sessions = new Map<string, Map<string, AutoAcceptSessionInfo>>()
isEnabled(instanceId: string, sessionId: string): boolean {
const root = this.familyRoot(instanceId, sessionId)
return this.enabled.get(instanceId)?.has(root) ?? false
}
setEnabled(instanceId: string, sessionId: string, enabled: boolean): void {
const root = this.familyRoot(instanceId, sessionId)
let roots = this.enabled.get(instanceId)
if (!roots) {
if (!enabled) return
roots = new Set()
this.enabled.set(instanceId, roots)
}
if (enabled) {
roots.add(root)
} else {
roots.delete(root)
if (roots.size === 0) {
this.enabled.delete(instanceId)
}
}
}
toggle(instanceId: string, sessionId: string): boolean {
const next = !this.isEnabled(instanceId, sessionId)
this.setEnabled(instanceId, sessionId, next)
return next
}
upsertSession(instanceId: string, info: AutoAcceptSessionInfo): void {
let tree = this.sessions.get(instanceId)
if (!tree) {
tree = new Map()
this.sessions.set(instanceId, tree)
}
tree.set(info.id, {
id: info.id,
parentId: info.parentId ?? null,
revert: info.revert,
})
this.migrateEnabledRoots(instanceId)
}
removeSession(instanceId: string, sessionId: string): void {
this.sessions.get(instanceId)?.delete(sessionId)
}
clearInstance(instanceId: string): void {
this.sessions.delete(instanceId)
this.enabled.delete(instanceId)
}
/** Resolves the family-root session id for the given session. */
familyRoot(instanceId: string, sessionId: string): string {
const tree = this.sessions.get(instanceId)
return resolveFamilyRoot(sessionId, (id) => tree?.get(id))
}
/**
* Re-resolves every enabled family root for an instance after the session
* tree changes (new session, updated parent/revert). If a root now resolves
* to a different id, the enabled entry is migrated so toggles survive late
* ancestry discovery.
*/
private migrateEnabledRoots(instanceId: string): void {
const roots = this.enabled.get(instanceId)
if (!roots || roots.size === 0) return
for (const oldRoot of Array.from(roots)) {
const newRoot = this.familyRoot(instanceId, oldRoot)
if (newRoot !== oldRoot) {
roots.delete(oldRoot)
roots.add(newRoot)
}
}
}
}

View file

@ -0,0 +1,47 @@
import type { WorkspaceManager } from "../workspaces/manager"
import type { Logger } from "../logger"
import { createInstanceClient } from "../workspaces/instance-client"
import type { AutoAcceptReply, PermissionReplier } from "./auto-accept-manager"
interface OpencodeReplierDeps {
workspaceManager: WorkspaceManager
logger: Logger
}
/**
* Default {@link PermissionReplier} that calls the OpenCode instance via the
* generated SDK client over loopback, using the same `"once"` reply the UI
* previously sent.
*
* Uses `createInstanceClient` so routes and body shapes are always correct
* for the installed SDK version no hand-assembled URLs.
*/
export function createOpencodePermissionReplier(deps: OpencodeReplierDeps): PermissionReplier {
return async (reply: AutoAcceptReply) => {
const client = createInstanceClient(deps.workspaceManager, reply.instanceId)
if (!client) {
throw new Error(`Yolo: instance ${reply.instanceId} has no open port`)
}
const opts = { throwOnError: true } as const
if (reply.source === "v2") {
await client.v2.session.permission.reply(
{
sessionID: reply.sessionId,
requestID: reply.permissionId,
reply: reply.reply,
},
opts,
)
} else {
await client.permission.reply(
{
requestID: reply.permissionId,
reply: reply.reply,
},
opts,
)
}
}
}

View file

@ -22,6 +22,7 @@ import { registerEventRoutes } from "./routes/events"
import { registerStorageRoutes } from "./routes/storage"
import { registerPluginRoutes } from "./routes/plugin"
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { registerYoloRoutes } from "./routes/yolo"
import { registerWorktreeRoutes } from "./routes/worktrees"
import { registerSpeechRoutes } from "./routes/speech"
import { registerRemoteServerRoutes } from "./routes/remote-servers"
@ -31,6 +32,8 @@ import { registerPreviewRoutes } from "./routes/previews"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager"
import { AutoAcceptManager } from "../permissions/auto-accept-manager"
import { createOpencodePermissionReplier } from "../permissions/opencode-replier"
import type { AuthManager } from "../auth/manager"
import { registerAuthRoutes } from "./routes/auth"
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
@ -192,6 +195,16 @@ export function createHttpServer(deps: HttpServerDeps) {
logger: deps.logger.child({ component: "background-processes" }),
})
const yoloManager = new AutoAcceptManager({
eventBus: deps.eventBus,
logger: deps.logger.child({ component: "yolo" }),
replier: createOpencodePermissionReplier({
workspaceManager: deps.workspaceManager,
logger: deps.logger.child({ component: "yolo" }),
}),
})
yoloManager.start()
registerAuthRoutes(app, { authManager: deps.authManager })
app.addHook("preHandler", (request, reply, done) => {
@ -309,6 +322,7 @@ export function createHttpServer(deps: HttpServerDeps) {
voiceModeManager: deps.voiceModeManager,
})
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
registerYoloRoutes(app, { yoloManager })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
@ -371,6 +385,7 @@ export function createHttpServer(deps: HttpServerDeps) {
return { port: actualPort, url: serverUrl, displayHost }
},
stop: () => {
yoloManager.stop()
closeSseClients()
return app.close()
},

View file

@ -0,0 +1,26 @@
import { FastifyInstance } from "fastify"
import type { AutoAcceptManager } from "../../permissions/auto-accept-manager"
interface RouteDeps {
yoloManager: AutoAcceptManager
}
export function registerYoloRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get<{ Params: { id: string; sessionId: string } }>(
"/workspaces/:id/yolo/sessions/:sessionId",
async (request) => {
const { id, sessionId } = request.params
return { enabled: deps.yoloManager.isEnabled(id, sessionId) }
},
)
app.post<{ Params: { id: string; sessionId: string } }>(
"/workspaces/:id/yolo/sessions/:sessionId/toggle",
async (request, reply) => {
const { id, sessionId } = request.params
const enabled = deps.yoloManager.toggle(id, sessionId)
reply.code(200)
return { enabled }
},
)
}

View file

@ -0,0 +1,49 @@
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { WorkspaceManager } from "./manager"
const INSTANCE_HOST = "127.0.0.1"
const LOOPBACK_TIMEOUT_MS = 10_000
/**
* Creates an OpenCode SDK client for direct loopback communication with a
* running workspace instance.
*
* Routes and body shapes come from the auto-generated SDK contract
* (`@opencode-ai/sdk`), eliminating handwritten URL construction that can
* drift between SDK versions. Other server modules that need to call the
* OpenCode instance directly should use this factory rather than building
* `http://127.0.0.1:{port}/...` URLs by hand.
*
* All requests carry a 10-second timeout loopback calls should be
* near-instant; a hang indicates a stuck instance.
*
* The client is cheap to create (object only, no connection); create one per
* call or cache per instance as needed. Returns `null` when the instance has
* no open port yet.
*/
export function createInstanceClient(
workspaceManager: WorkspaceManager,
instanceId: string,
): OpencodeClient | null {
const port = workspaceManager.getInstancePort(instanceId)
if (!port) return null
const headers: Record<string, string> = {}
const authorization = workspaceManager.getInstanceAuthorizationHeader(instanceId)
if (authorization) {
headers.authorization = authorization
}
const workspace = workspaceManager.get(instanceId)
return createOpencodeClient({
baseUrl: `http://${INSTANCE_HOST}:${port}/`,
headers,
fetch: (url, init) =>
fetch(url, {
...(init as RequestInit),
signal: (init as RequestInit)?.signal ?? AbortSignal.timeout(LOOPBACK_TIMEOUT_MS),
}),
...(workspace?.path ? { directory: workspace.path } : {}),
})
}

View file

@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Node",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,

View file

@ -22,6 +22,7 @@ import type {
RemoteServerProbeRequest,
RemoteServerProbeResponse,
VoiceModeStateResponse,
YoloStateResponse,
WorkspaceCloneRequest,
WorkspaceCloneResponse,
WorktreeGitCommitRequest,
@ -520,6 +521,17 @@ export const serverApi = {
body: JSON.stringify({ ...identity, enabled }),
})
},
getYoloState(instanceId: string, sessionId: string): Promise<YoloStateResponse> {
return request<YoloStateResponse>(
`/workspaces/${encodeURIComponent(instanceId)}/yolo/sessions/${encodeURIComponent(sessionId)}`,
)
},
toggleYolo(instanceId: string, sessionId: string): Promise<YoloStateResponse> {
return request<YoloStateResponse>(
`/workspaces/${encodeURIComponent(instanceId)}/yolo/sessions/${encodeURIComponent(sessionId)}/toggle`,
{ method: "POST" },
)
},
sendClientConnectionPong(payload: { clientId: string; connectionId: string; pingTs?: number }, signal?: AbortSignal): Promise<void> {
const init: RequestInit = {
method: "POST",

View file

@ -41,10 +41,10 @@ import {
pruneRepliedPermissions,
} from "./permission-replies"
import {
clearAutoAcceptPermission,
drainAutoAcceptPermissions,
clearPermissionAutoAcceptForInstance,
isPermissionAutoAcceptEnabled,
resolvePermissionAutoAcceptFamilyRoot,
setPermissionAutoAcceptEnabled,
setPermissionAutoAcceptFamilyRootResolver,
togglePermissionAutoAccept,
} from "./permission-auto-accept"
@ -65,6 +65,31 @@ setPermissionAutoAcceptFamilyRootResolver((instanceId, sessionId) => {
return resolvePermissionAutoAcceptFamilyRoot(sessionId, (id) => instanceSessions.get(id))
})
// Server is authoritative for Yolo state; mirror toggles (incl. from other
// clients) arriving over the CodeNomad server event stream into the local
// projection so the badge/switch stay in sync.
serverEvents.on("yolo.stateChanged", (event) => {
if (event.type !== "yolo.stateChanged") return
const { instanceId, sessionId, enabled } = event
if (typeof instanceId !== "string" || typeof sessionId !== "string" || typeof enabled !== "boolean") return
log.info(`[SSE] Yolo state changed: ${instanceId}:${sessionId} -> ${enabled}`)
setPermissionAutoAcceptEnabled(instanceId, sessionId, enabled)
})
// When the server auto-accepts a permission, clean up the UI queue immediately
// instead of waiting for the OpenCode permission.replied SSE event (which may
// be delayed or missed on a flaky connection). This preserves the #424
// invariant: auto-accept cleanup happens at the queue level, not tied to
// external event timing.
serverEvents.on("yolo.autoAccepted", (event) => {
if (event.type !== "yolo.autoAccepted") return
const { instanceId, permissionId } = event
if (typeof instanceId !== "string" || typeof permissionId !== "string") return
markPermissionReplied(instanceId, permissionId)
removePermissionFromQueue(instanceId, permissionId)
removePermissionV2(instanceId, permissionId)
})
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
const [activeInstanceId, setActiveInstanceId] = createSignal<string | null>(null)
@ -326,7 +351,6 @@ async function syncPendingPermissions(instanceId: string): Promise<void> {
const queuedPermission = addPermissionToQueue(instanceId, permission, source) ?? permission
upsertPermissionV2(instanceId, queuedPermission)
}
drainAutoAcceptPermissions(instanceId, getPermissionQueue(instanceId), sendPermissionResponse, hasPendingPermission)
} catch (error) {
log.warn("Failed to sync pending permissions", { instanceId, error })
}
@ -519,6 +543,8 @@ function handleWorkspaceEvent(event: WorkspaceEventPayload) {
case "workspace.error":
upsertWorkspace(event.workspace)
showWorkspaceLaunchError(event.workspace)
clearPermissionAutoAcceptForInstance(event.workspace.id)
clearSyncedYoloSessionsForInstance(event.workspace.id)
break
case "workspace.stopped":
releaseInstanceResources(event.workspaceId)
@ -657,6 +683,8 @@ function removeInstance(id: string) {
clearRepliedPermissions(id)
clearQuestionQueue(id)
clearInstanceMetadata(id)
clearPermissionAutoAcceptForInstance(id)
clearSyncedYoloSessionsForInstance(id)
if (activeInstanceId() === id) {
setActiveInstanceId(nextActiveId)
@ -1001,7 +1029,6 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequest,
}
drainAutoAcceptPermissions(instanceId, [queuedPermission], sendPermissionResponse, hasPendingPermission)
return queuedPermission
}
@ -1037,7 +1064,6 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
if (removed) {
const removedSessionId = getPermissionSessionId(removed)
if (removedSessionId) {
clearAutoAcceptPermission(instanceId, removedSessionId, permissionId)
const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
setSessionPendingPermission(instanceId, removedSessionId, remaining > 0)
}
@ -1045,23 +1071,61 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo
}
function togglePermissionAutoAcceptForSession(instanceId: string, sessionId: string): void {
const willEnable = !isPermissionAutoAcceptEnabled(instanceId, sessionId)
const wasEnabled = isPermissionAutoAcceptEnabled(instanceId, sessionId)
togglePermissionAutoAccept(instanceId, sessionId)
if (!willEnable) return
drainAutoAcceptPermissionsForInstance(instanceId)
void serverApi
.toggleYolo(instanceId, sessionId)
.then((state) => {
setPermissionAutoAcceptEnabled(instanceId, sessionId, state.enabled)
})
.catch((error) => {
log.warn("Failed to toggle Yolo on server", { instanceId, sessionId, error })
// revert to the pre-toggle state (not a naive flip, which can be wrong
// if an SSE yolo.stateChanged arrived between toggle and catch)
setPermissionAutoAcceptEnabled(instanceId, sessionId, wasEnabled)
})
}
function drainAutoAcceptPermissionsForInstance(instanceId: string): void {
drainAutoAcceptPermissions(instanceId, getPermissionQueue(instanceId), sendPermissionResponse, hasPendingPermission)
/**
* Sessions whose Yolo state has been backfilled from the server. The server is
* authoritative but only pushes changes (`yolo.stateChanged`); a freshly
* connected client must fetch the effective state for a session so the badge
* matches reality from the start. De-duped per session and reset on SSE
* reconnect so state re-syncs after a server restart.
*/
const syncedYoloSessions = new Set<string>()
export function ensureYoloStateSynced(instanceId: string, sessionId: string): void {
if (!instanceId || !sessionId || sessionId === "info") return
const key = `${instanceId}:${sessionId}`
if (syncedYoloSessions.has(key)) return
syncedYoloSessions.add(key)
void serverApi
.getYoloState(instanceId, sessionId)
.then((state) => {
setPermissionAutoAcceptEnabled(instanceId, sessionId, state.enabled)
})
.catch((error) => {
// allow retry on next activation (e.g. instance not ready yet)
syncedYoloSessions.delete(key)
log.warn("Failed to sync Yolo state", { instanceId, sessionId, error })
})
}
serverEvents.onOpen(() => {
syncedYoloSessions.clear()
})
function clearSyncedYoloSessionsForInstance(instanceId: string): void {
const prefix = `${instanceId}:`
for (const key of Array.from(syncedYoloSessions)) {
if (key.startsWith(prefix)) {
syncedYoloSessions.delete(key)
}
}
}
function clearPermissionQueue(instanceId: string): void {
for (const permission of getPermissionQueue(instanceId)) {
const sessionId = getPermissionSessionId(permission)
if (sessionId) {
clearAutoAcceptPermission(instanceId, sessionId, permission.id)
}
}
for (const permission of getPermissionQueue(instanceId)) {
permissionEnqueuedAt.delete(permission.id)
}
@ -1391,7 +1455,6 @@ export {
markPermissionReplied,
hasRepliedPermission,
togglePermissionAutoAcceptForSession,
drainAutoAcceptPermissionsForInstance,
clearPermissionQueue,
sendPermissionResponse,
setActivePermissionIdForInstance,

View file

@ -1,15 +1,28 @@
import { createSignal } from "solid-js"
import type { PermissionReply, PermissionRequest } from "../types/permission"
import { getPermissionSessionId } from "../types/permission"
import { getLogger } from "../lib/logger"
const STORAGE_KEY = "codenomad:permission-auto-accept:v1"
/**
* UI-side mirror of the server-owned Yolo (permission auto-accept) state.
*
* The server is authoritative: it holds the toggle state, resolves
* family-root inheritance, and performs the actual `"once"` replies. This
* module only keeps a runtime (NON-persisted) projection so the UI can render
* the badge / switch synchronously.
*
* State is populated from:
* - local toggles (optimistic, then confirmed via REST)
* - `yolo.stateChanged` SSE events (wired in the app bootstrap, see
* `stores/instances.ts`, so toggles from other clients reflect)
*
* `resolvePermissionAutoAcceptFamilyRoot` is retained as a display aid so the
* badge correctly lights up for child/sub-sessions of an enabled family root,
* preserving the previous inheritance UX exactly. The server performs the same
* resolution independently when deciding whether to auto-reply.
*
* NOTE: intentionally pure no SSE/REST side effects at module load, so it
* stays unit-testable.
*/
const log = getLogger("api")
type AutoAcceptResponder = (instanceId: string, sessionId: string, requestId: string, reply: PermissionReply) => Promise<void>
type PendingPermissionChecker = (instanceId: string, requestId: string) => boolean
type PermissionAutoAcceptSession = {
export type PermissionAutoAcceptSession = {
id: string
parentId?: string | null
revert?: unknown
@ -45,112 +58,44 @@ function makeKey(instanceId: string, sessionId: string) {
return `${instanceId}:${resolveFamilyRoot(instanceId, sessionId)}`
}
function readInitialState() {
if (typeof window === "undefined" || !window.localStorage) {
return new Map<string, boolean>()
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return new Map<string, boolean>()
const parsed = JSON.parse(raw) as Record<string, boolean>
return new Map(Object.entries(parsed).filter((entry): entry is [string, boolean] => entry[1] === true))
} catch {
return new Map<string, boolean>()
}
}
function persist(next: Map<string, boolean>) {
if (typeof window === "undefined" || !window.localStorage) {
return
}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(next)))
} catch {
// ignore persistence failures
}
}
const [autoAcceptState, setAutoAcceptState] = createSignal(readInitialState())
const inFlight = new Set<string>()
const [autoAcceptState, setAutoAcceptState] = createSignal<Map<string, boolean>>(new Map())
export function isPermissionAutoAcceptEnabled(instanceId: string, sessionId: string) {
return autoAcceptState().get(makeKey(instanceId, sessionId)) ?? false
}
export function setPermissionAutoAcceptEnabled(instanceId: string, sessionId: string, enabled: boolean) {
const key = makeKey(instanceId, sessionId)
setAutoAcceptState((prev) => {
const key = makeKey(instanceId, sessionId)
if (prev.get(key) === enabled) return prev
const next = new Map(prev)
if (enabled) {
next.set(key, true)
} else {
next.delete(key)
}
persist(next)
return next
})
if (!enabled) {
clearAutoAcceptSession(instanceId, sessionId)
}
}
export function togglePermissionAutoAccept(instanceId: string, sessionId: string) {
setPermissionAutoAcceptEnabled(instanceId, sessionId, !isPermissionAutoAcceptEnabled(instanceId, sessionId))
const next = !isPermissionAutoAcceptEnabled(instanceId, sessionId)
setPermissionAutoAcceptEnabled(instanceId, sessionId, next)
return next
}
function makeRequestKey(instanceId: string, sessionId: string, requestId: string) {
return `${makeKey(instanceId, sessionId)}:${requestId}`
}
export function clearAutoAcceptPermission(instanceId: string, sessionId: string, requestId: string) {
const requestKey = makeRequestKey(instanceId, sessionId, requestId)
inFlight.delete(requestKey)
}
export function clearAutoAcceptSession(instanceId: string, sessionId: string) {
const prefix = `${makeKey(instanceId, sessionId)}:`
for (const requestKey of Array.from(inFlight)) {
if (requestKey.startsWith(prefix)) {
inFlight.delete(requestKey)
/** Remove all Yolo state entries for an instance (workspace stop / removal). */
export function clearPermissionAutoAcceptForInstance(instanceId: string) {
setAutoAcceptState((prev) => {
const prefix = `${instanceId}:`
let changed = false
const next = new Map(prev)
for (const key of Array.from(next.keys())) {
if (key.startsWith(prefix)) {
next.delete(key)
changed = true
}
}
}
}
export function drainAutoAcceptPermission(
instanceId: string,
permission: PermissionRequest,
responder: AutoAcceptResponder,
isPending: PendingPermissionChecker,
) {
const sessionId = getPermissionSessionId(permission)
if (!sessionId || !permission?.id) return
if (!isPermissionAutoAcceptEnabled(instanceId, sessionId)) return
if (!isPending(instanceId, permission.id)) return
const requestKey = makeRequestKey(instanceId, sessionId, permission.id)
if (inFlight.has(requestKey)) return
inFlight.add(requestKey)
void responder(instanceId, sessionId, permission.id, "once")
.catch((error) => {
log.error("Failed to auto-accept permission", error)
})
.finally(() => {
inFlight.delete(requestKey)
})
}
export function drainAutoAcceptPermissions(
instanceId: string,
permissions: PermissionRequest[],
responder: AutoAcceptResponder,
isPending: PendingPermissionChecker,
) {
for (const permission of permissions) {
drainAutoAcceptPermission(instanceId, permission, responder, isPending)
}
return changed ? next : prev
})
}

View file

@ -50,7 +50,6 @@ import {
hasRepliedPermission,
addQuestionToQueue,
removeQuestionFromQueue,
drainAutoAcceptPermissionsForInstance,
} from "./instances"
import { showAlertDialog } from "./alerts"
import {
@ -235,7 +234,6 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
let updatedInstanceSessions: Map<string, Session> | undefined
let shouldExpandParent: string | null = null
let shouldDrainAutoAcceptPermissions = false
setSessions((prev) => {
const next = new Map(prev)
@ -258,7 +256,6 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
instanceSessions.set(sessionId, merged)
next.set(instanceId, instanceSessions)
updatedInstanceSessions = instanceSessions
shouldDrainAutoAcceptPermissions = Boolean(merged.parentId)
if (merged.parentId && merged.status === "working" && (existing?.status ?? "idle") !== "working") {
shouldExpandParent = merged.parentId
@ -268,10 +265,6 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
syncInstanceSessionIndicator(instanceId, updatedInstanceSessions)
if (shouldDrainAutoAcceptPermissions) {
drainAutoAcceptPermissionsForInstance(instanceId)
}
if (shouldExpandParent) {
ensureSessionParentExpanded(instanceId, shouldExpandParent)
}
@ -542,9 +535,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
syncInstanceSessionIndicator(instanceId, updatedInstanceSessions)
setSessionRevertV2(instanceId, info.id, info.revert ?? null)
if (newSession.parentId) {
drainAutoAcceptPermissionsForInstance(instanceId)
} else {
if (!newSession.parentId) {
prependSessionListId(instanceId, newSession.id)
}
@ -585,9 +576,6 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
syncInstanceSessionIndicator(instanceId, updatedInstanceSessions)
setSessionRevertV2(instanceId, info.id, info.revert ?? null)
if (updatedSession.parentId) {
drainAutoAcceptPermissionsForInstance(instanceId)
}
}
}

View file

@ -4,7 +4,7 @@ import { getIdleSinceForStatusTransition, type Session, type SessionStatus, type
import { deleteSession, loadMessages } from "./session-api"
import { showToastNotification } from "../lib/notifications"
import { messageStoreBus } from "./message-v2/bus"
import { instances } from "./instances"
import { instances, ensureYoloStateSynced } from "./instances"
import { showConfirmDialog } from "./alerts"
import { getLogger } from "../lib/logger"
import { requestData } from "../lib/opencode-api"
@ -483,6 +483,9 @@ function setActiveSession(instanceId: string, sessionId: string): void {
next.set(instanceId, sessionId)
return next
})
// Backfill authoritative Yolo state for the now-active session so the badge
// matches the server even on first connect / multi-client scenarios.
ensureYoloStateSynced(instanceId, sessionId)
}
function setActiveParentSession(instanceId: string, parentSessionId: string): void {