Skip to content

Commit ca06bd9

Browse files
feat(yolo): move permission auto-accept (Yolo) to the server (NeuralNomadsAI#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>
1 parent bce975d commit ca06bd9

20 files changed

Lines changed: 1583 additions & 146 deletions

.opencode/skills/codenomad-architecture-guide/references/feature-traces.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ End-to-end feature flows with decision branches and mechanism references.
77
1. **Server:** Backend emits SSE event `permission.asked` or `permission.updated`
88
- Events are pushed through the instance event stream
99

10-
2. **UI Store:** `packages/ui/src/stores/instances.ts` receives via `serverEvents` handler
11-
- **Branch:** IF `isPermissionAutoAcceptEnabled(instanceId, sessionId)` is true
12-
- **Mechanism:** `drainAutoAcceptPermissions()` in `packages/ui/src/stores/permission-auto-accept.ts`
13-
- **Action:** Automatically calls `Permission.reply()`, skips modal display
14-
- **File:** `packages/ui/src/stores/permission-auto-accept.ts:drainAutoAcceptPermission()`
10+
2. **Server AutoAcceptManager** intercepts permission events (if Yolo is enabled)
11+
- **File:** `packages/server/src/permissions/auto-accept-manager.ts`
12+
- **Action:** Auto-replies via SDK client, emits `yolo.autoAccepted` to UI for immediate queue cleanup
13+
- **Pending drain:** Re-drains pending permissions on toggle(enable) and session ancestry changes
14+
3. **UI Store:** `packages/ui/src/stores/instances.ts` receives via `serverEvents`
15+
- **Branch:** IF `yolo.autoAccepted` event arrives → marks replied + removes from queue immediately
1516
- **Branch:** ELSE (normal flow)
1617
- **Mechanism:** Permission queued in `permissionQueues` signal
1718
- **Action:** Display approval modal

.opencode/skills/codenomad-architecture-guide/references/sdk-integration-patterns.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -129,20 +129,20 @@ Rapid successive operations can cause temporary desync:
129129

130130
1. **Server emits** `permission.asked` or `permission.updated` SSE event
131131
- Pushed through instance event stream
132-
2. **UI Store receives** via `serverEvents`
132+
2. **Server AutoAcceptManager** intercepts the event (if Yolo is enabled)
133+
- File: `packages/server/src/permissions/auto-accept-manager.ts`
134+
- Action: Auto-replies via SDK client (`createInstanceClient`), tracks pending permissions, drains on enable/ancestry change
135+
- Emits `yolo.autoAccepted` + `yolo.stateChanged` events to UI
136+
3. **UI Store receives** via `serverEvents`
133137
- File: `packages/ui/src/stores/instances.ts`
134-
- **Branch:** IF `isPermissionAutoAcceptEnabled()`
135-
- Mechanism: `drainAutoAcceptPermissions()` in `packages/ui/src/stores/permission-auto-accept.ts`
136-
- Action: Calls reply immediately, skips modal
137-
- **Branch:** ELSE
138-
- Mechanism: Queued in `permissionQueues`
139-
- Action: Display modal
140-
3. **UI Store:** `packages/ui/src/stores/message-v2/bridge.ts` calls `upsertPermissionV2()`
141-
4. **UI Component:** `packages/ui/src/components/permission-approval-modal.tsx` displays
142-
5. **User Action:** Calls `packages/ui/src/stores/instances.ts:sendPermissionResponse()`
143-
6. **SDK Call:** `client.permission.reply()` via `packages/ui/src/lib/opencode-api.ts`
144-
7. **Optimistic Update:** `removePermissionV2()` in bridge
145-
8. **SSE Confirmation:** `permission.replied` event
138+
- **Branch:** IF `yolo.autoAccepted` event arrives → immediately marks replied + removes from queue
139+
- **Branch:** ELSE (user must reply) → Queued in `permissionQueues` → Display modal
140+
4. **UI Store:** `packages/ui/src/stores/message-v2/bridge.ts` calls `upsertPermissionV2()`
141+
5. **UI Component:** `packages/ui/src/components/permission-approval-modal.tsx` displays
142+
6. **User Action:** Calls `packages/ui/src/stores/instances.ts:sendPermissionResponse()`
143+
7. **SDK Call:** `client.permission.reply()` via `packages/ui/src/lib/opencode-api.ts`
144+
8. **Optimistic Update:** `removePermissionV2()` in bridge
145+
9. **SSE Confirmation:** `permission.replied` event
146146
- **Branch:** IF SSE disconnected → `syncPendingPermissions()` reconciles on reconnect
147147

148148
## Session Event Handling

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"@fastify/cors": "^8.5.0",
2929
"@fastify/reply-from": "^9.8.0",
3030
"@fastify/static": "^7.0.4",
31+
"@opencode-ai/sdk": "^1.17.8",
3132
"commander": "^12.1.0",
3233
"fastify": "^4.28.1",
3334
"fuzzysort": "^2.0.4",

packages/server/src/api-types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,10 @@ export interface VoiceModeStateResponse {
365365
enabled: boolean
366366
}
367367

368+
export interface YoloStateResponse {
369+
enabled: boolean
370+
}
371+
368372
export interface RemoteServerProfile {
369373
id: string
370374
name: string
@@ -414,6 +418,8 @@ export type WorkspaceEventType =
414418
| "instance.dataChanged"
415419
| "instance.event"
416420
| "instance.eventStatus"
421+
| "yolo.stateChanged"
422+
| "yolo.autoAccepted"
417423

418424
export type WorkspaceEventPayload =
419425
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
@@ -428,6 +434,8 @@ export type WorkspaceEventPayload =
428434
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
429435
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
430436
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
437+
| { type: "yolo.stateChanged"; instanceId: string; sessionId: string; enabled: boolean }
438+
| { type: "yolo.autoAccepted"; instanceId: string; sessionId: string; permissionId: string }
431439

432440
export interface NetworkAddress {
433441
ip: string

packages/server/src/events/bus.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ export class EventBus extends EventEmitter {
3131
this.on("instance.dataChanged", handler)
3232
this.on("instance.event", handler)
3333
this.on("instance.eventStatus", handler)
34+
this.on("yolo.stateChanged", handler)
35+
this.on("yolo.autoAccepted", handler)
3436
return () => {
3537
this.off("workspace.created", handler)
3638
this.off("workspace.started", handler)
@@ -44,6 +46,8 @@ export class EventBus extends EventEmitter {
4446
this.off("instance.dataChanged", handler)
4547
this.off("instance.event", handler)
4648
this.off("instance.eventStatus", handler)
49+
this.off("yolo.stateChanged", handler)
50+
this.off("yolo.autoAccepted", handler)
4751
}
4852
}
4953
}

0 commit comments

Comments
 (0)