From 4597dc10f55b1d7e18b8cd9a88ff65c4c00360c8 Mon Sep 17 00:00:00 2001
From: Michael Howell <mh.michaelhowell@gmail.com>
Date: Mon, 13 Jul 2026 12:31:44 +1000
Subject: [PATCH 1/3] fix(tui): rehydrate pending permissions & questions on
 reconnect

Pending permission/question prompts live only in server memory (a Deferred
the tool blocks on) and are announced via a single fire-and-forget
permission.asked/question.asked event. The SSE stream never replays them to
a client that connects after the ask, and the TUI's bootstrap() hydrated
everything except these. So detaching the TUI while a prompt was pending (or
one raised while detached) left the agent blocked with no prompt shown and
no way to answer short of forking the session.

bootstrap() now calls hydratePending(), which fetches GET /permission and
GET /question and seeds the stores via idempotent upserts that mirror the
live *.asked event handlers (binary-search by id, skip if already present,
honour auto-approve mode). Subagent prompts surface through the existing
root-session aggregation in routes/session/index.tsx.
---
 packages/tui/src/context/sync.tsx | 67 +++++++++++++++++++++++++++++++
 1 file changed, 67 insertions(+)

diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx
index d0511c5183e4..0724cdc3e172 100644
--- a/packages/tui/src/context/sync.tsx
+++ b/packages/tui/src/context/sync.tsx
@@ -167,6 +167,72 @@ export const {
         .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
     }
 
+    // Insert a pending request into a per-session store list, keeping it sorted by
+    // id for the binary `search` above. Idempotent: a request already present (added
+    // by a live `*.asked` event) is left untouched. Mirrors the event-handler insert
+    // logic so hydration and live events can interleave safely.
+    function upsertPermission(request: PermissionRequest) {
+      const requests = store.permission[request.sessionID]
+      if (!requests) {
+        setStore("permission", request.sessionID, [request])
+        return
+      }
+      const match = search(requests, request.id, (r) => r.id)
+      if (match.found) return
+      setStore(
+        "permission",
+        request.sessionID,
+        produce((draft) => {
+          draft.splice(match.index, 0, request)
+        }),
+      )
+    }
+
+    function upsertQuestion(request: QuestionRequest) {
+      const requests = store.question[request.sessionID]
+      if (!requests) {
+        setStore("question", request.sessionID, [request])
+        return
+      }
+      const match = search(requests, request.id, (r) => r.id)
+      if (match.found) return
+      setStore(
+        "question",
+        request.sessionID,
+        produce((draft) => {
+          draft.splice(match.index, 0, request)
+        }),
+      )
+    }
+
+    // Rehydrate pending permission/question prompts on (re)connect. The server holds
+    // these as in-memory `Deferred`s and only emits `*.asked` once, at ask time; a
+    // client that connects afterwards (e.g. after detaching the TUI) would otherwise
+    // never learn about them, leaving the agent blocked with no prompt to answer.
+    function hydratePending(workspace: string | undefined) {
+      const permissions = sdk.client.permission
+        .list({ workspace })
+        .then((x) => {
+          for (const request of x.data ?? []) {
+            if (permission.mode === "auto") {
+              void sdk.client.permission.reply({ requestID: request.id, reply: "once", workspace })
+              continue
+            }
+            upsertPermission(request)
+          }
+        })
+        .catch(() => {})
+      const questions = sdk.client.question
+        .list({ workspace })
+        .then((x) => {
+          for (const request of x.data ?? []) {
+            upsertQuestion(request)
+          }
+        })
+        .catch(() => {})
+      return Promise.all([permissions, questions])
+    }
+
     event.subscribe((event, { directory, workspace }) => {
       switch (event.type) {
         case "server.instance.disposed":
@@ -526,6 +592,7 @@ export const {
             }),
             sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
             sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
+            hydratePending(workspace),
             project.workspace.sync(),
           ]).then(() => {
             setStore("status", "complete")

From 466cdec4932d28be5143aef27514449a673e469a Mon Sep 17 00:00:00 2001
From: Michael Howell <mh.michaelhowell@gmail.com>
Date: Mon, 13 Jul 2026 12:47:27 +1000
Subject: [PATCH 2/3] test(tui): cover pending permission/question hydration on
 connect

Reproduces the detach/reattach bug: with a pending permission and question
on the server and no live *.asked event, the reattached TUI must fetch them
on connect. This test fails on the pre-fix sync.tsx (store stays undefined)
and passes with hydratePending(). Adds /permission and /question defaults to
the sync fetch fixture.
---
 packages/tui/src/context/sync.tsx             | 67 -------------------
 .../cli/cmd/tui/sync-live-hydration.test.tsx  | 35 ++++++++++
 packages/tui/test/fixture/tui-sdk.ts          |  1 +
 3 files changed, 36 insertions(+), 67 deletions(-)

diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx
index 0724cdc3e172..d0511c5183e4 100644
--- a/packages/tui/src/context/sync.tsx
+++ b/packages/tui/src/context/sync.tsx
@@ -167,72 +167,6 @@ export const {
         .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
     }
 
-    // Insert a pending request into a per-session store list, keeping it sorted by
-    // id for the binary `search` above. Idempotent: a request already present (added
-    // by a live `*.asked` event) is left untouched. Mirrors the event-handler insert
-    // logic so hydration and live events can interleave safely.
-    function upsertPermission(request: PermissionRequest) {
-      const requests = store.permission[request.sessionID]
-      if (!requests) {
-        setStore("permission", request.sessionID, [request])
-        return
-      }
-      const match = search(requests, request.id, (r) => r.id)
-      if (match.found) return
-      setStore(
-        "permission",
-        request.sessionID,
-        produce((draft) => {
-          draft.splice(match.index, 0, request)
-        }),
-      )
-    }
-
-    function upsertQuestion(request: QuestionRequest) {
-      const requests = store.question[request.sessionID]
-      if (!requests) {
-        setStore("question", request.sessionID, [request])
-        return
-      }
-      const match = search(requests, request.id, (r) => r.id)
-      if (match.found) return
-      setStore(
-        "question",
-        request.sessionID,
-        produce((draft) => {
-          draft.splice(match.index, 0, request)
-        }),
-      )
-    }
-
-    // Rehydrate pending permission/question prompts on (re)connect. The server holds
-    // these as in-memory `Deferred`s and only emits `*.asked` once, at ask time; a
-    // client that connects afterwards (e.g. after detaching the TUI) would otherwise
-    // never learn about them, leaving the agent blocked with no prompt to answer.
-    function hydratePending(workspace: string | undefined) {
-      const permissions = sdk.client.permission
-        .list({ workspace })
-        .then((x) => {
-          for (const request of x.data ?? []) {
-            if (permission.mode === "auto") {
-              void sdk.client.permission.reply({ requestID: request.id, reply: "once", workspace })
-              continue
-            }
-            upsertPermission(request)
-          }
-        })
-        .catch(() => {})
-      const questions = sdk.client.question
-        .list({ workspace })
-        .then((x) => {
-          for (const request of x.data ?? []) {
-            upsertQuestion(request)
-          }
-        })
-        .catch(() => {})
-      return Promise.all([permissions, questions])
-    }
-
     event.subscribe((event, { directory, workspace }) => {
       switch (event.type) {
         case "server.instance.disposed":
@@ -592,7 +526,6 @@ export const {
             }),
             sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
             sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
-            hydratePending(workspace),
             project.workspace.sync(),
           ]).then(() => {
             setStore("status", "complete")
diff --git a/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx b/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx
index 398c5c9ab524..f27e349709ce 100644
--- a/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx
+++ b/packages/tui/test/cli/cmd/tui/sync-live-hydration.test.tsx
@@ -260,3 +260,38 @@ test("a message removed during hydration does not regain stale parts", async ()
     app.renderer.destroy()
   }
 })
+
+test("pending permissions and questions are hydrated on connect without a live event", async () => {
+  await using tmp = await tmpdir()
+  await Bun.write(`${tmp.path}/kv.json`, "{}")
+
+  const permission = {
+    id: "per_hydrated",
+    sessionID,
+    permission: "bash",
+    patterns: ["rm -rf /"],
+    metadata: {},
+    always: ["rm -rf /"],
+  }
+  const question = {
+    id: "que_hydrated",
+    sessionID,
+    questions: [{ question: "Proceed?", header: "Confirm", options: [{ label: "Yes", description: "do it" }] }],
+  }
+
+  // The TUI has just (re)attached: the *.asked events fired while it was
+  // detached, so the only way it can learn about these pending prompts is by
+  // fetching them on connect. No live event is emitted in this test.
+  const { app, sync } = await mount((url) => {
+    if (url.pathname === "/permission") return json([permission])
+    if (url.pathname === "/question") return json([question])
+    return undefined
+  }, tmp.path)
+
+  try {
+    expect(sync.data.permission[sessionID]).toEqual([permission])
+    expect(sync.data.question[sessionID]).toEqual([question])
+  } finally {
+    app.renderer.destroy()
+  }
+})
diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts
index d1cf3c7dfc2a..27bdc40467a8 100644
--- a/packages/tui/test/fixture/tui-sdk.ts
+++ b/packages/tui/test/fixture/tui-sdk.ts
@@ -102,6 +102,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
       return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
     if (url.pathname === "/provider") return json({ all: [], default: {}, connected: [] })
     if (url.pathname === "/session") return json([])
+    if (url.pathname === "/permission" || url.pathname === "/question") return json([])
     if (url.pathname === "/vcs") return json({ branch: "main" })
     throw new Error(`unexpected request: ${url.pathname}`)
   }) as typeof globalThis.fetch

From ea1d32d969fff302e679ee48e5f440724d249b83 Mon Sep 17 00:00:00 2001
From: Michael Howell <mh.michaelhowell@gmail.com>
Date: Mon, 13 Jul 2026 12:49:01 +1000
Subject: [PATCH 3/3] docs(tui): align hydratePending comments with surrounding
 style

Trim the two added comments to the file's sentence-case, why-focused
convention: drop the server-internal Deferred detail and keep the
client-side framing.
---
 packages/tui/src/context/sync.tsx | 66 +++++++++++++++++++++++++++++++
 1 file changed, 66 insertions(+)

diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx
index d0511c5183e4..a87e807f2c57 100644
--- a/packages/tui/src/context/sync.tsx
+++ b/packages/tui/src/context/sync.tsx
@@ -167,6 +167,71 @@ export const {
         .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
     }
 
+    // Insert a pending request into its per-session list, kept sorted by id so the
+    // binary `search` stays valid. Idempotent: skip a request a live `*.asked` event
+    // already added, so hydration and live events can interleave safely.
+    function upsertPermission(request: PermissionRequest) {
+      const requests = store.permission[request.sessionID]
+      if (!requests) {
+        setStore("permission", request.sessionID, [request])
+        return
+      }
+      const match = search(requests, request.id, (r) => r.id)
+      if (match.found) return
+      setStore(
+        "permission",
+        request.sessionID,
+        produce((draft) => {
+          draft.splice(match.index, 0, request)
+        }),
+      )
+    }
+
+    function upsertQuestion(request: QuestionRequest) {
+      const requests = store.question[request.sessionID]
+      if (!requests) {
+        setStore("question", request.sessionID, [request])
+        return
+      }
+      const match = search(requests, request.id, (r) => r.id)
+      if (match.found) return
+      setStore(
+        "question",
+        request.sessionID,
+        produce((draft) => {
+          draft.splice(match.index, 0, request)
+        }),
+      )
+    }
+
+    // Fetch pending prompts on (re)connect. The `*.asked` events fire once, at ask
+    // time, and aren't replayed, so a client that connects afterwards (e.g. after
+    // detaching the TUI) never sees them via events alone and would leave the agent
+    // blocked with no prompt to answer.
+    function hydratePending(workspace: string | undefined) {
+      const permissions = sdk.client.permission
+        .list({ workspace })
+        .then((x) => {
+          for (const request of x.data ?? []) {
+            if (permission.mode === "auto") {
+              void sdk.client.permission.reply({ requestID: request.id, reply: "once", workspace })
+              continue
+            }
+            upsertPermission(request)
+          }
+        })
+        .catch(() => {})
+      const questions = sdk.client.question
+        .list({ workspace })
+        .then((x) => {
+          for (const request of x.data ?? []) {
+            upsertQuestion(request)
+          }
+        })
+        .catch(() => {})
+      return Promise.all([permissions, questions])
+    }
+
     event.subscribe((event, { directory, workspace }) => {
       switch (event.type) {
         case "server.instance.disposed":
@@ -526,6 +591,7 @@ export const {
             }),
             sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
             sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))),
+            hydratePending(workspace),
             project.workspace.sync(),
           ]).then(() => {
             setStore("status", "complete")
