diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index d2349cde..32eea774 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -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 diff --git a/packages/server/src/events/bus.ts b/packages/server/src/events/bus.ts index fd1e3ce6..7929c7e2 100644 --- a/packages/server/src/events/bus.ts +++ b/packages/server/src/events/bus.ts @@ -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) } } } diff --git a/packages/server/src/permissions/auto-accept-manager.test.ts b/packages/server/src/permissions/auto-accept-manager.test.ts new file mode 100644 index 00000000..42865179 --- /dev/null +++ b/packages/server/src/permissions/auto-accept-manager.test.ts @@ -0,0 +1,306 @@ +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) { + 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, +) { + 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[] = [] + 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[] = [] + 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) + }) +}) + +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((resolve) => setImmediate(resolve)) +} diff --git a/packages/server/src/permissions/auto-accept-manager.ts b/packages/server/src/permissions/auto-accept-manager.ts new file mode 100644 index 00000000..14cc1e70 --- /dev/null +++ b/packages/server/src/permissions/auto-accept-manager.ts @@ -0,0 +1,171 @@ +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 + +interface AutoAcceptManagerDeps { + eventBus: EventBus + logger: Logger + replier: PermissionReplier +} + +const PERMISSION_EVENT_TYPES = new Set(["permission.v2.asked", "permission.asked", "permission.updated"]) +const SESSION_UPSERT_TYPES = new Set(["session.updated", "session.created"]) +const SESSION_REMOVE_TYPES = new Set(["session.deleted"]) + +export class AutoAcceptManager { + private readonly store = new AutoAcceptStore() + /** instanceId:permissionId entries currently being replied, to dedupe re-emissions */ + private readonly inFlight = new Set() + 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 }) + return enabled + } + + clearInstance(instanceId: string): void { + this.store.clearInstance(instanceId) + } + + 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) + return + } + if (PERMISSION_EVENT_TYPES.has(event.type)) { + this.maybeAutoAccept(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 }) + } + + private maybeAutoAccept(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 + if (!this.store.isEnabled(instanceId, sessionId)) return + + const key = `${instanceId}:${permissionId}` + if (this.inFlight.has(key)) return + this.inFlight.add(key) + + const source: PermissionSource = eventType === "permission.v2.asked" ? "v2" : "legacy" + const reply: AutoAcceptReply = { instanceId, permissionId, sessionId, source, reply: "once" } + + void this.deps.replier(reply) + .then(() => { + this.deps.eventBus.publish({ type: "yolo.autoAccepted", instanceId, sessionId, permissionId }) + }) + .catch((error) => { + this.deps.logger.error({ instanceId, permissionId, err: error }, "Yolo auto-accept reply failed") + }) + .finally(() => { + this.inFlight.delete(key) + }) + } +} + +interface InstanceStreamPayload { + type?: string + properties?: Record +} + +interface SessionProperties { + id?: string + parentID?: string | null + parentId?: string | null + revert?: unknown +} + +interface PermissionProperties { + id?: string + sessionID?: string + sessionId?: string +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined +} diff --git a/packages/server/src/permissions/auto-accept-store.test.ts b/packages/server/src/permissions/auto-accept-store.test.ts new file mode 100644 index 00000000..68b19422 --- /dev/null +++ b/packages/server/src/permissions/auto-accept-store.test.ts @@ -0,0 +1,184 @@ +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("re-evaluates family 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 + store.upsertSession("inst", { id: "parent", parentId: null }) + // child now resolves to "parent"; the original setting was on "child" + // so the parent family is NOT enabled (child's own root id was recorded) + assert.equal(store.isEnabled("inst", "parent"), false) + assert.equal(store.isEnabled("inst", "child"), false) + }) + + 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) + }) +}) diff --git a/packages/server/src/permissions/auto-accept-store.ts b/packages/server/src/permissions/auto-accept-store.ts new file mode 100644 index 00000000..5c6abf2a --- /dev/null +++ b/packages/server/src/permissions/auto-accept-store.ts @@ -0,0 +1,108 @@ +/** + * 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() + + 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>() + /** instanceId -> (sessionId -> info) */ + private readonly sessions = new Map>() + + isEnabled(instanceId: string, sessionId: string): boolean { + const root = this.resolveRoot(instanceId, sessionId) + return this.enabled.get(instanceId)?.has(root) ?? false + } + + setEnabled(instanceId: string, sessionId: string, enabled: boolean): void { + const root = this.resolveRoot(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, + }) + } + + removeSession(instanceId: string, sessionId: string): void { + this.sessions.get(instanceId)?.delete(sessionId) + } + + clearInstance(instanceId: string): void { + this.sessions.delete(instanceId) + this.enabled.delete(instanceId) + } + + private resolveRoot(instanceId: string, sessionId: string): string { + const tree = this.sessions.get(instanceId) + return resolveFamilyRoot(sessionId, (id) => tree?.get(id)) + } +} diff --git a/packages/server/src/permissions/opencode-replier.ts b/packages/server/src/permissions/opencode-replier.ts new file mode 100644 index 00000000..1ed5d4e6 --- /dev/null +++ b/packages/server/src/permissions/opencode-replier.ts @@ -0,0 +1,54 @@ +import type { WorkspaceManager } from "../workspaces/manager" +import type { Logger } from "../logger" +import { fetch } from "undici" +import type { AutoAcceptReply, PermissionReplier } from "./auto-accept-manager" + +const INSTANCE_HOST = "127.0.0.1" + +interface OpencodeReplierDeps { + workspaceManager: WorkspaceManager + logger: Logger +} + +/** + * Default {@link PermissionReplier} that calls the OpenCode instance directly + * over loopback using the same `"once"` reply the UI previously sent. + * + * - v2: POST /session/{sessionID}/permissions/{permissionID} body { response } + * - legacy: POST /permission/{requestID}/reply body { reply } + * + * Mirrors the per-instance direct-call pattern used by the background-process + * notifier (`background-processes/manager.ts`). + */ +export function createOpencodePermissionReplier(deps: OpencodeReplierDeps): PermissionReplier { + return async (reply: AutoAcceptReply) => { + const port = deps.workspaceManager.getInstancePort(reply.instanceId) + if (!port) { + throw new Error(`Yolo: instance ${reply.instanceId} has no open port`) + } + + const headers: Record = { "content-type": "application/json" } + const authorization = deps.workspaceManager.getInstanceAuthorizationHeader(reply.instanceId) + if (authorization) { + headers.authorization = authorization + } + + const url = + reply.source === "v2" + ? `http://${INSTANCE_HOST}:${port}/session/${encodeURIComponent(reply.sessionId)}/permissions/${encodeURIComponent(reply.permissionId)}` + : `http://${INSTANCE_HOST}:${port}/permission/${encodeURIComponent(reply.permissionId)}/reply` + + const body = + reply.source === "v2" + ? JSON.stringify({ response: reply.reply }) + : JSON.stringify({ reply: reply.reply }) + + const response = await fetch(url, { method: "POST", headers, body }) + if (!response.ok) { + const text = await response.text().catch(() => "") + throw new Error( + `Yolo reply failed (${reply.source}): ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`, + ) + } + } +} diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 5dd85468..5eb760e6 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -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,17 @@ 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() + sseClients.add(() => yoloManager.stop()) + registerAuthRoutes(app, { authManager: deps.authManager }) app.addHook("preHandler", (request, reply, done) => { @@ -309,6 +323,7 @@ export function createHttpServer(deps: HttpServerDeps) { voiceModeManager: deps.voiceModeManager, }) registerBackgroundProcessRoutes(app, { backgroundProcessManager }) + registerYoloRoutes(app, { yoloManager }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) diff --git a/packages/server/src/server/routes/yolo.ts b/packages/server/src/server/routes/yolo.ts new file mode 100644 index 00000000..ddd66985 --- /dev/null +++ b/packages/server/src/server/routes/yolo.ts @@ -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 } + }, + ) +} diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 353360ea..9cd6b02a 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -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 { + return request( + `/workspaces/${encodeURIComponent(instanceId)}/yolo/sessions/${encodeURIComponent(sessionId)}`, + ) + }, + toggleYolo(instanceId: string, sessionId: string): Promise { + return request( + `/workspaces/${encodeURIComponent(instanceId)}/yolo/sessions/${encodeURIComponent(sessionId)}/toggle`, + { method: "POST" }, + ) + }, sendClientConnectionPong(payload: { clientId: string; connectionId: string; pingTs?: number }, signal?: AbortSignal): Promise { const init: RequestInit = { method: "POST", diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 96fddf6d..a4b81900 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -41,10 +41,9 @@ import { pruneRepliedPermissions, } from "./permission-replies" import { - clearAutoAcceptPermission, - drainAutoAcceptPermissions, isPermissionAutoAcceptEnabled, resolvePermissionAutoAcceptFamilyRoot, + setPermissionAutoAcceptEnabled, setPermissionAutoAcceptFamilyRootResolver, togglePermissionAutoAccept, } from "./permission-auto-accept" @@ -65,6 +64,17 @@ 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) +}) + const [instances, setInstances] = createSignal>(new Map()) const [activeInstanceId, setActiveInstanceId] = createSignal(null) @@ -326,7 +336,6 @@ async function syncPendingPermissions(instanceId: string): Promise { 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 }) } @@ -1001,7 +1010,6 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequest, } - drainAutoAcceptPermissions(instanceId, [queuedPermission], sendPermissionResponse, hasPendingPermission) return queuedPermission } @@ -1037,7 +1045,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 +1052,50 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo } function togglePermissionAutoAcceptForSession(instanceId: string, sessionId: string): void { - const willEnable = !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 optimistic local state on failure + setPermissionAutoAcceptEnabled(instanceId, sessionId, !isPermissionAutoAcceptEnabled(instanceId, sessionId)) + }) } -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() + +export function ensureYoloStateSynced(instanceId: string, sessionId: string): void { + if (!instanceId || !sessionId) 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 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 +1425,6 @@ export { markPermissionReplied, hasRepliedPermission, togglePermissionAutoAcceptForSession, - drainAutoAcceptPermissionsForInstance, clearPermissionQueue, sendPermissionResponse, setActivePermissionIdForInstance, diff --git a/packages/ui/src/stores/permission-auto-accept.ts b/packages/ui/src/stores/permission-auto-accept.ts index aca94793..de025653 100644 --- a/packages/ui/src/stores/permission-auto-accept.ts +++ b/packages/ui/src/stores/permission-auto-accept.ts @@ -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 -type PendingPermissionChecker = (instanceId: string, requestId: string) => boolean -type PermissionAutoAcceptSession = { +export type PermissionAutoAcceptSession = { id: string parentId?: string | null revert?: unknown @@ -45,112 +58,28 @@ function makeKey(instanceId: string, sessionId: string) { return `${instanceId}:${resolveFamilyRoot(instanceId, sessionId)}` } -function readInitialState() { - if (typeof window === "undefined" || !window.localStorage) { - return new Map() - } - - try { - const raw = window.localStorage.getItem(STORAGE_KEY) - if (!raw) return new Map() - const parsed = JSON.parse(raw) as Record - return new Map(Object.entries(parsed).filter((entry): entry is [string, boolean] => entry[1] === true)) - } catch { - return new Map() - } -} - -function persist(next: Map) { - 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() +const [autoAcceptState, setAutoAcceptState] = createSignal>(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)) -} - -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) - } - } -} - -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) - } + const next = !isPermissionAutoAcceptEnabled(instanceId, sessionId) + setPermissionAutoAcceptEnabled(instanceId, sessionId, next) + return next } diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 1742e325..36473700 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -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 | 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) - } } } diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index eb9cf36a..634d9df7 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -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 {