From 88523c8eb5714145e2b0cc3e99c70ce604698327 Mon Sep 17 00:00:00 2001
From: deepagent-ai
Date: Fri, 24 Jul 2026 01:39:15 +0800
Subject: [PATCH 01/22] =?UTF-8?q?fix(startup):=20revert=20overlay=20splash?=
=?UTF-8?q?=20=E2=80=94=20blocked=20all=20pointer=20events?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The overlay approach (app renders behind z-9999 splash) had a critical
flaw: while !appReady(), the fixed-inset overlay captured ALL pointer
events. Users could see the project icons but could not click them.
Reverted to the original Show/fallback splash structure. Removed:
- appReadyFired effect + deepagent-code:app-ready event dispatch
- firstSessionLoadDone signal + Promise.all loadSessions wrapper
- Overlay <> fragment in renderer/index.tsx
- createSignal import in layout.tsx
Kept (non-blocking improvements from prior commit):
- sidebar.opened defaults to false (no blank-panel flash)
- terminal panel transition suppressed until layoutReady()
- SidebarPanel shows SessionSkeleton instead of blank
- bottom/side terminal pty.create gated on runtimeId (no cold-start 503)
- pty.create 10s deadline
- F1/F3/F5/F6/F2 telemetry unchanged
Co-Authored-By: Claude Opus 4.8 (1M context)
---
packages/app/src/pages/layout.tsx | 35 +--------------
packages/desktop/src/renderer/index.tsx | 57 ++++++-------------------
2 files changed, 15 insertions(+), 77 deletions(-)
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index a4c2ed93..d14ad19a 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -3,7 +3,6 @@ import {
createEffect,
createMemo,
createResource,
- createSignal,
For,
on,
onCleanup,
@@ -1925,10 +1924,6 @@ export default function Layout(props: ParentProps) {
const loadedSessionDirs = new Set()
- // F2-splash: signal that the first session-list fetch has completed so the
- // renderer knows the sidebar is populated and the splash can be dismissed.
- const [firstSessionLoadDone, setFirstSessionLoadDone] = createSignal(false)
-
createEffect(
on(
visibleSessionDirs,
@@ -1939,48 +1934,20 @@ export default function Layout(props: ParentProps) {
}
const next = new Set(dirs)
- const loads: Promise[] = []
for (const directory of next) {
if (loadedSessionDirs.has(directory)) continue
- loads.push(serverSync.project.loadSessions(directory))
+ void serverSync.project.loadSessions(directory)
}
loadedSessionDirs.clear()
for (const directory of next) {
loadedSessionDirs.add(directory)
}
-
- if (loads.length > 0) {
- void Promise.all(loads).then(() => setFirstSessionLoadDone(true))
- } else {
- // All visible directories were already loaded on a prior run.
- setFirstSessionLoadDone(true)
- }
},
{ defer: true },
),
)
- // Fire `deepagent-code:app-ready` exactly once when every gate is satisfied:
- // 1. server persist loaded (projects list populated — was missing, caused early fire)
- // 2. layout persist loaded (project list available)
- // 3. page persist loaded (last-session info available)
- // 4. autoselecting resolved (router has navigated to the last project)
- // 5. first sessions fetch done — OR no projects exist (nothing to wait for)
- // The renderer's splash stays up until this event fires (5 s failsafe there).
- let appReadyFired = false
- createEffect(() => {
- if (appReadyFired) return
- if (!server.ready()) return // server persist must be loaded (populates projects list)
- if (!layoutReady()) return
- if (!pageReady()) return
- if (autoselecting.loading) return
- const projects = layout.projects.list()
- if (projects.length > 0 && !firstSessionLoadDone()) return
- appReadyFired = true
- window.dispatchEvent(new Event("deepagent-code:app-ready"))
- })
-
function handleDragStart(event: unknown) {
const id = getDraggableId(event)
if (!id) return
diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx
index 8ad8d93f..8ff32622 100644
--- a/packages/desktop/src/renderer/index.tsx
+++ b/packages/desktop/src/renderer/index.tsx
@@ -335,32 +335,17 @@ render(() => {
function App() {
const wslServers = useWslServers()
-
- // App-ready gate: the app layer fires "deepagent-code:app-ready" once layout
- // persist + routing + first sessions fetch are all done. A 5-second failsafe
- // prevents a permanent hang. We use an OVERLAY approach: the app renders
- // immediately (so Layout can mount and do its async work), and the splash sits
- // on top until appReady fires. This avoids the circular dependency where the
- // app can never fire the event because it can't render until the event fires.
- const [appReady, setAppReady] = createSignal(false)
- onMount(() => {
- const fallback = window.setTimeout(() => setAppReady(true), 5_000)
- window.addEventListener(
- "deepagent-code:app-ready",
- () => {
- clearTimeout(fallback)
- setAppReady(true)
- },
- { once: true },
- )
- onCleanup(() => clearTimeout(fallback))
- })
+ const splash = (
+
+
+
+ )
const ready = createMemo(
() => !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading,
)
- // renderer.initialization — end: basic resources resolved.
+ // renderer.initialization — end: basic resources resolved, app is about to mount.
createEffect(() => {
if (!ready() || rendererReadyLogged) return
rendererReadyLogged = true
@@ -394,29 +379,15 @@ render(() => {
)
return (
- <>
- {/* App renders immediately so Layout can mount and do async startup work. */}
-
-
- {(key) => (
-
-
-
- )}
-
-
- {/*
- * Splash overlay: sits on top until both server resources are ready AND
- * the app layer has signalled session-UI readiness (deepagent-code:app-ready).
- * The 5-second failsafe in the onMount above ensures this never hangs
- * permanently even if the async chain encounters an error.
- */}
-
-
-
-
+
+
+ {(key) => (
+
+
+
+ )}
- >
+
)
}
From f26ce31a1f784340ce40dd5bb2dfd280b434e8f7 Mon Sep 17 00:00:00 2001
From: deepagent-ai
Date: Fri, 24 Jul 2026 09:22:49 +0800
Subject: [PATCH 02/22] =?UTF-8?q?fix(startup):=20visibility:hidden=20appro?=
=?UTF-8?q?ach=20=E2=80=94=20D=20stays=20until=20all=20data=20ready?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Use visibility:hidden instead of z-index overlay. The app renders in the
background (Layout mounts, data loads) while completely invisible and
non-interactive. Once all gates pass, D disappears and the app is
immediately visible and fully ready.
Architecture:
- renderer/index.tsx: app wrapped in visibility:hidden div until appReady;
splash overlay (z-9999) stays while !ready || !appReady
- layout.tsx: appReadyFired effect gates on server.ready() + layoutReady()
+ pageReady() + !autoselecting.loading + firstSessionLoadDone()
- firstSessionLoadDone fires when current project's session list completes
Why visibility:hidden (not overlay):
- visibility:hidden removes pointer events from the element and all children
→ no click-blocking side effects even if splash somehow disappears early
- The app is truly invisible (no rendering artifacts, no partial paint)
- Once ready, app becomes visible instantly with all data already in place
Failsafe: 8-second timeout in renderer ensures splash never hangs
permanently on slow networks. Once D ends, sidebar panel and terminal
are already mounting in background (user requirement: those can load
lazily after D).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
packages/app/src/pages/layout.tsx | 37 ++++++++++++++++-
packages/desktop/src/renderer/index.tsx | 53 +++++++++++++++++++------
2 files changed, 76 insertions(+), 14 deletions(-)
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index d14ad19a..60421815 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -3,6 +3,7 @@ import {
createEffect,
createMemo,
createResource,
+ createSignal,
For,
on,
onCleanup,
@@ -1924,6 +1925,9 @@ export default function Layout(props: ParentProps) {
const loadedSessionDirs = new Set()
+ // Track when the first (current project) session list has finished loading.
+ const [firstSessionLoadDone, setFirstSessionLoadDone] = createSignal(false)
+
createEffect(
on(
visibleSessionDirs,
@@ -1934,20 +1938,51 @@ export default function Layout(props: ParentProps) {
}
const next = new Set(dirs)
+ const loads: Promise[] = []
for (const directory of next) {
if (loadedSessionDirs.has(directory)) continue
- void serverSync.project.loadSessions(directory)
+ loads.push(serverSync.project.loadSessions(directory))
}
loadedSessionDirs.clear()
for (const directory of next) {
loadedSessionDirs.add(directory)
}
+
+ if (loads.length > 0) {
+ void Promise.all(loads).then(() => setFirstSessionLoadDone(true))
+ } else {
+ setFirstSessionLoadDone(true)
+ }
},
{ defer: true },
),
)
+ // Signal to renderer that the app is fully ready: projects loaded, last session
+ // navigated to, and the current session list fetched. Renderer keeps splash visible
+ // (with app hidden via visibility:hidden) until this fires.
+ // Gates:
+ // 1. server persist loaded → projects list populated
+ // 2. layout/page persist loaded → last-session info available
+ // 3. autoselecting resolved → router has navigated to the last project
+ // 4. firstSessionLoadDone → current project sessions listed in sidebar
+ // 8-second failsafe in the renderer prevents a permanent hang.
+ let appReadyFired = false
+ createEffect(() => {
+ if (appReadyFired) return
+ if (!server.ready()) return
+ if (!layoutReady()) return
+ if (!pageReady()) return
+ if (autoselecting.loading) return
+ const projects = layout.projects.list()
+ // If there are projects, wait until the first session list load completes
+ // so the sidebar shows real sessions rather than skeletons.
+ if (projects.length > 0 && !firstSessionLoadDone()) return
+ appReadyFired = true
+ window.dispatchEvent(new Event("deepagent-code:app-ready"))
+ })
+
function handleDragStart(event: unknown) {
const id = getDraggableId(event)
if (!id) return
diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx
index 8ff32622..60494ca0 100644
--- a/packages/desktop/src/renderer/index.tsx
+++ b/packages/desktop/src/renderer/index.tsx
@@ -335,11 +335,25 @@ render(() => {
function App() {
const wslServers = useWslServers()
- const splash = (
-
-
-
- )
+
+ // App-data gate: Layout fires "deepagent-code:app-ready" once the session UI
+ // is fully prepared (projects loaded, last session navigated to, sessions
+ // listed). Until then the app renders in the background but is completely
+ // invisible via `visibility:hidden` — no pointer events, no visual flash.
+ // An 8-second failsafe prevents a permanent hang on slow networks.
+ const [appReady, setAppReady] = createSignal(false)
+ onMount(() => {
+ const fallback = window.setTimeout(() => setAppReady(true), 8_000)
+ window.addEventListener(
+ "deepagent-code:app-ready",
+ () => {
+ clearTimeout(fallback)
+ setAppReady(true)
+ },
+ { once: true },
+ )
+ onCleanup(() => clearTimeout(fallback))
+ })
const ready = createMemo(
() => !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading,
@@ -379,15 +393,28 @@ render(() => {
)
return (
-
-
- {(key) => (
-
-
-
- )}
+ <>
+ {/* Splash: shows while server resources load OR while app data preloads.
+ Uses a fixed overlay so the hidden app can render in the background. */}
+
+
+
+
+
+ {/* App: renders as soon as server is ready, but hidden until data is preloaded.
+ visibility:hidden means no rendering artifacts, no pointer events, no flash. */}
+
+
+
+ {(key) => (
+
+
+
+ )}
+
+
-
+ >
)
}
From f6dcca9f08b9cabf36c9de8bfe31cdfc7a3c1aae Mon Sep 17 00:00:00 2001
From: deepagent-ai
Date: Fri, 24 Jul 2026 09:30:29 +0800
Subject: [PATCH 03/22] =?UTF-8?q?Revert=20"fix(startup):=20visibility:hidd?=
=?UTF-8?q?en=20approach=20=E2=80=94=20D=20stays=20until=20all=20data=20re?=
=?UTF-8?q?ady"?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This reverts commit 1c9d7f19ad9350ce4041a443fcbda0b97b9a3ac3.
---
packages/app/src/pages/layout.tsx | 37 +----------------
packages/desktop/src/renderer/index.tsx | 53 ++++++-------------------
2 files changed, 14 insertions(+), 76 deletions(-)
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 60421815..d14ad19a 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -3,7 +3,6 @@ import {
createEffect,
createMemo,
createResource,
- createSignal,
For,
on,
onCleanup,
@@ -1925,9 +1924,6 @@ export default function Layout(props: ParentProps) {
const loadedSessionDirs = new Set()
- // Track when the first (current project) session list has finished loading.
- const [firstSessionLoadDone, setFirstSessionLoadDone] = createSignal(false)
-
createEffect(
on(
visibleSessionDirs,
@@ -1938,51 +1934,20 @@ export default function Layout(props: ParentProps) {
}
const next = new Set(dirs)
- const loads: Promise[] = []
for (const directory of next) {
if (loadedSessionDirs.has(directory)) continue
- loads.push(serverSync.project.loadSessions(directory))
+ void serverSync.project.loadSessions(directory)
}
loadedSessionDirs.clear()
for (const directory of next) {
loadedSessionDirs.add(directory)
}
-
- if (loads.length > 0) {
- void Promise.all(loads).then(() => setFirstSessionLoadDone(true))
- } else {
- setFirstSessionLoadDone(true)
- }
},
{ defer: true },
),
)
- // Signal to renderer that the app is fully ready: projects loaded, last session
- // navigated to, and the current session list fetched. Renderer keeps splash visible
- // (with app hidden via visibility:hidden) until this fires.
- // Gates:
- // 1. server persist loaded → projects list populated
- // 2. layout/page persist loaded → last-session info available
- // 3. autoselecting resolved → router has navigated to the last project
- // 4. firstSessionLoadDone → current project sessions listed in sidebar
- // 8-second failsafe in the renderer prevents a permanent hang.
- let appReadyFired = false
- createEffect(() => {
- if (appReadyFired) return
- if (!server.ready()) return
- if (!layoutReady()) return
- if (!pageReady()) return
- if (autoselecting.loading) return
- const projects = layout.projects.list()
- // If there are projects, wait until the first session list load completes
- // so the sidebar shows real sessions rather than skeletons.
- if (projects.length > 0 && !firstSessionLoadDone()) return
- appReadyFired = true
- window.dispatchEvent(new Event("deepagent-code:app-ready"))
- })
-
function handleDragStart(event: unknown) {
const id = getDraggableId(event)
if (!id) return
diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx
index 60494ca0..8ff32622 100644
--- a/packages/desktop/src/renderer/index.tsx
+++ b/packages/desktop/src/renderer/index.tsx
@@ -335,25 +335,11 @@ render(() => {
function App() {
const wslServers = useWslServers()
-
- // App-data gate: Layout fires "deepagent-code:app-ready" once the session UI
- // is fully prepared (projects loaded, last session navigated to, sessions
- // listed). Until then the app renders in the background but is completely
- // invisible via `visibility:hidden` — no pointer events, no visual flash.
- // An 8-second failsafe prevents a permanent hang on slow networks.
- const [appReady, setAppReady] = createSignal(false)
- onMount(() => {
- const fallback = window.setTimeout(() => setAppReady(true), 8_000)
- window.addEventListener(
- "deepagent-code:app-ready",
- () => {
- clearTimeout(fallback)
- setAppReady(true)
- },
- { once: true },
- )
- onCleanup(() => clearTimeout(fallback))
- })
+ const splash = (
+
+
+
+ )
const ready = createMemo(
() => !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading,
@@ -393,28 +379,15 @@ render(() => {
)
return (
- <>
- {/* Splash: shows while server resources load OR while app data preloads.
- Uses a fixed overlay so the hidden app can render in the background. */}
-
-
-
-
-
- {/* App: renders as soon as server is ready, but hidden until data is preloaded.
- visibility:hidden means no rendering artifacts, no pointer events, no flash. */}
-
-
@@ -2583,11 +2571,8 @@ export default function Layout(props: ParentProps) {
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
- // Suppress the left-slide transition until the layout persist store
- // has loaded so the initial open→close (or closed→open) snap from
- // async storage doesn't animate visibly.
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
- !state.sizing && layoutReady(),
+ !state.sizing,
}}
style={{
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
From 610746d469563bb6626337dd266ad9509f85a613 Mon Sep 17 00:00:00 2001
From: deepagent-ai
Date: Fri, 24 Jul 2026 10:37:38 +0800
Subject: [PATCH 05/22] fix(sidebar): fundamental fix for solid-dnd pointer
sensor listener leak
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Root cause: solid-dnd 0.7.5 createPointerSensor registers document-level
pointermove/pointerup listeners on pointerdown, but its onCleanup only
calls removeSensor() — never detach(). After any DragDropSensors remount
(triggered by project switching), the old document listeners are orphaned.
Accumulated stale onPointerMove handlers call event.preventDefault() on
all pointer events, making the sidebar unresponsive after 2-3 switches.
Second omission: solid-dnd has no pointercancel handler, so any gesture
cancelled by the OS (scroll-lock, focus change, page navigation) also
permanently leaks the document listeners.
Fix: implement FixedDragDropSensors in packages/app/src/utils/solid-dnd.tsx
— a drop-in replacement for DragDropSensors that:
1. Calls detach() in onCleanup before removeSensor(), ensuring document
listeners are always removed when the component unmounts mid-gesture
2. Adds a pointercancel handler (absent in solid-dnd entirely)
Replace DragDropSensors with FixedDragDropSensors in all three usages:
- packages/app/src/pages/layout/sidebar-shell.tsx (left rail — main site)
- packages/app/src/pages/layout.tsx (workspace DnD panel)
- packages/app/src/pages/session/terminal-view.tsx (terminal pane DnD)
Also removes the previous temporary workaround (synthetic pointerup/
pointercancel dispatch in openProject) — now unnecessary.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
packages/app/src/pages/layout.tsx | 6 +-
.../app/src/pages/layout/sidebar-shell.tsx | 5 +-
.../app/src/pages/session/terminal-view.tsx | 6 +-
packages/app/src/utils/solid-dnd.tsx | 123 +++++++++++++++++-
4 files changed, 129 insertions(+), 11 deletions(-)
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 9cd70c53..42a325f1 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -30,7 +30,7 @@ import { Session, type Message } from "@deepagent-code/sdk/v2/client"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { createStore, produce, reconcile } from "solid-js/store"
-import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
+import { DragDropProvider, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useProviders } from "@/hooks/use-providers"
import { toaster } from "@deepagent-code/ui/toast"
@@ -61,7 +61,7 @@ import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
import { useDialog } from "@deepagent-code/ui/context/dialog"
import { useTheme, type ColorScheme } from "@deepagent-code/ui/theme/context"
import { useCommand, type CommandOption } from "@/context/command"
-import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
+import { ConstrainDragXAxis, getDraggableId, FixedDragDropSensors } from "@/utils/solid-dnd"
import { DebugBar } from "@/components/debug-bar"
import { listPending } from "@/components/review/dialog-review.api"
import { fetchCapabilities } from "@/components/deepagent/panel-goal.api"
@@ -2369,7 +2369,7 @@ export default function Layout(props: ParentProps) {
onDragOver={handleWorkspaceDragOver}
collisionDetector={closestCenter}
>
-
+
{
diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx
index eaca8a6f..b83caf53 100644
--- a/packages/app/src/pages/layout/sidebar-shell.tsx
+++ b/packages/app/src/pages/layout/sidebar-shell.tsx
@@ -1,13 +1,12 @@
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import {
DragDropProvider,
- DragDropSensors,
DragOverlay,
SortableProvider,
closestCenter,
type DragEvent,
} from "@thisbeyond/solid-dnd"
-import { ConstrainDragXAxis } from "@/utils/solid-dnd"
+import { ConstrainDragXAxis, FixedDragDropSensors } from "@/utils/solid-dnd"
import { IconButton } from "@deepagent-code/ui/icon-button"
import { Tooltip, TooltipKeybind } from "@deepagent-code/ui/tooltip"
import { type LocalProject } from "@/context/layout"
@@ -72,7 +71,7 @@ export const SidebarContent = (props: {
onDragOver={props.handleDragOver}
collisionDetector={closestCenter}
>
-
+
p.worktree)}>
diff --git a/packages/app/src/pages/session/terminal-view.tsx b/packages/app/src/pages/session/terminal-view.tsx
index 3ff8df0e..203061a5 100644
--- a/packages/app/src/pages/session/terminal-view.tsx
+++ b/packages/app/src/pages/session/terminal-view.tsx
@@ -4,9 +4,9 @@ import { ResizeHandle } from "@deepagent-code/ui/resize-handle"
import { IconButton } from "@deepagent-code/ui/icon-button"
import { Button } from "@deepagent-code/ui/button"
import { TooltipKeybind, Tooltip } from "@deepagent-code/ui/tooltip"
-import { DragDropProvider, DragDropSensors, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
+import { DragDropProvider, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
-import { ConstrainDragYAxis } from "@/utils/solid-dnd"
+import { ConstrainDragYAxis, FixedDragDropSensors } from "@/utils/solid-dnd"
import { SortableTerminalTab } from "@/components/session"
import { Terminal } from "@/components/terminal"
@@ -221,7 +221,7 @@ function LeafPane(props: { node: PaneLeaf }) {
onFocusIn={() => terminal.setFocusedPane(props.node.id)}
>
-
+
diff --git a/packages/app/src/utils/solid-dnd.tsx b/packages/app/src/utils/solid-dnd.tsx
index 8e30a033..6c3bb3d5 100644
--- a/packages/app/src/utils/solid-dnd.tsx
+++ b/packages/app/src/utils/solid-dnd.tsx
@@ -1,6 +1,6 @@
import { useDragDropContext } from "@thisbeyond/solid-dnd"
-import type { Transformer } from "@thisbeyond/solid-dnd"
-import { createRoot, onCleanup, type JSXElement } from "solid-js"
+import type { Id, Transformer } from "@thisbeyond/solid-dnd"
+import { createRoot, onCleanup, onMount, type JSXElement } from "solid-js"
type DragEvent = { draggable?: { id?: unknown } }
@@ -47,3 +47,122 @@ const createAxisConstraint = (axis: "x" | "y", transformerId: string) => (): JSX
export const ConstrainDragXAxis = createAxisConstraint("x", "constrain-x-axis")
export const ConstrainDragYAxis = createAxisConstraint("y", "constrain-y-axis")
+
+// ---------------------------------------------------------------------------
+// FixedDragDropSensors — patches solid-dnd 0.7.5 pointer sensor bug.
+//
+// Bug: createPointerSensor's onCleanup only calls removeSensor(), never
+// detach(). This leaves document-level pointermove/pointerup listeners
+// orphaned after every DragDropSensors remount. Orphaned onPointerMove
+// handlers call event.preventDefault(), swallowing all subsequent pointer
+// events and making the UI unresponsive after ~2 project switches.
+//
+// Fix: reimplement createPointerSensor locally so that onCleanup calls
+// detach() BEFORE removeSensor(), and add a pointercancel handler that
+// solid-dnd omits entirely.
+// ---------------------------------------------------------------------------
+
+function createFixedPointerSensor(id = "pointer-sensor") {
+ const ctx = useDragDropContext()
+ if (!ctx) return
+
+ const [state, { addSensor, removeSensor, sensorStart, sensorMove, sensorEnd, dragStart, dragEnd }] = ctx
+
+ const activationDelay = 250
+ const activationDistance = 10
+
+ const initialCoordinates = { x: 0, y: 0 }
+ let activationDelayTimeoutId: number | null = null
+ let activationDraggableId: string | number | null = null
+
+ const isActiveSensor = () => state.active.sensorId === id
+
+ const clearSelection = () => window.getSelection()?.removeAllRanges()
+
+ const detach = () => {
+ if (activationDelayTimeoutId !== null) {
+ clearTimeout(activationDelayTimeoutId)
+ activationDelayTimeoutId = null
+ }
+ document.removeEventListener("pointermove", onPointerMove)
+ document.removeEventListener("pointerup", onPointerUp)
+ document.removeEventListener("pointercancel", onPointerCancel)
+ document.removeEventListener("selectionchange", clearSelection)
+ }
+
+ const onActivate = () => {
+ if (!state.active.sensor) {
+ sensorStart(id, initialCoordinates)
+ dragStart(activationDraggableId!)
+ clearSelection()
+ document.addEventListener("selectionchange", clearSelection)
+ } else if (!isActiveSensor()) {
+ detach()
+ }
+ }
+
+ const onPointerMove = (event: PointerEvent) => {
+ const coordinates = { x: event.clientX, y: event.clientY }
+ if (!state.active.sensor) {
+ const dx = coordinates.x - initialCoordinates.x
+ const dy = coordinates.y - initialCoordinates.y
+ if (Math.sqrt(dx * dx + dy * dy) > activationDistance) onActivate()
+ }
+ if (isActiveSensor()) {
+ event.preventDefault()
+ sensorMove(coordinates)
+ }
+ }
+
+ const onPointerUp = (event: PointerEvent) => {
+ detach()
+ if (isActiveSensor()) {
+ event.preventDefault()
+ dragEnd()
+ sensorEnd()
+ }
+ }
+
+ // solid-dnd omits this entirely — pointercancel fires when the OS/browser
+ // cancels the gesture (scroll-lock, focus loss, navigation). Without it
+ // the sensor stays "half-attached" indefinitely.
+ const onPointerCancel = () => {
+ detach()
+ if (isActiveSensor()) {
+ dragEnd()
+ sensorEnd()
+ }
+ }
+
+ const attach = (event: PointerEvent, draggableId: Id) => {
+ if (event.button !== 0) return
+ document.addEventListener("pointermove", onPointerMove)
+ document.addEventListener("pointerup", onPointerUp)
+ document.addEventListener("pointercancel", onPointerCancel)
+ activationDraggableId = draggableId
+ initialCoordinates.x = event.clientX
+ initialCoordinates.y = event.clientY
+ activationDelayTimeoutId = window.setTimeout(onActivate, activationDelay)
+ }
+
+ onMount(() => {
+ addSensor({ id, activators: { pointerdown: attach } })
+ })
+
+ onCleanup(() => {
+ // THE FIX: call detach() before removeSensor() so document listeners are
+ // always cleaned up, even if the component unmounts mid-gesture.
+ detach()
+ removeSensor(id)
+ })
+}
+
+/**
+ * Drop-in replacement for solid-dnd's DragDropSensors that fixes the pointer
+ * sensor's missing detach() call in onCleanup and adds pointercancel support.
+ */
+export const FixedDragDropSensors = (props: { children?: JSXElement }): JSXElement => {
+ createFixedPointerSensor()
+ return props.children as JSXElement
+}
+
From eb82166c996283fcb5c7eed41cdbc3ff121d4fdb Mon Sep 17 00:00:00 2001
From: deepagent-ai
Date: Fri, 24 Jul 2026 10:42:33 +0800
Subject: [PATCH 06/22] =?UTF-8?q?perf(startup):=20reduce=20server-side=20l?=
=?UTF-8?q?ayer=20build=20latency=20=E2=80=94=20r3=20fixes?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- event-dispatcher.ts / goal-tick-consumer.ts: wrap bare Deferred.await(ready)
in Effect.timeout(500ms) + Effect.ignore so a DB-stall never hangs the V4
EventRuntime layer build (fixes the K40-2 durable consumer registration race)
- deepagent-event-bus.ts: add dbGroupsCache to groupsFor — eliminates the
per-publish DB SELECT; cache is invalidated on registerConsumerGroup /
unregisterConsumerGroup so correctness is preserved
- database.ts: set wal_autocheckpoint = 200 pages (~800 KB) so individual
WAL-merge checkpoints are cheap instead of large infrequent write-lock spikes
Ref: docs/4.0.4_r3.md
Co-Authored-By: Claude Opus 4.8 (1M context)
---
packages/core/src/database/database.ts | 7 +++--
.../core/src/deepagent/deepagent-event-bus.ts | 29 ++++++++++++++++---
.../src/session/event-dispatcher.ts | 5 +++-
.../src/session/goal-tick-consumer.ts | 5 +++-
4 files changed, 38 insertions(+), 8 deletions(-)
diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts
index b0bf6a6a..7ef0cf68 100644
--- a/packages/core/src/database/database.ts
+++ b/packages/core/src/database/database.ts
@@ -29,8 +29,11 @@ export const layer = Layer.effect(
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA foreign_keys = ON")
- // wal_checkpoint removed from startup: it blocked the "ready" signal by 1-3 s
- // on large databases. SQLite auto-checkpoints at wal_autocheckpoint = 1000 pages.
+ // Tune WAL autocheckpoint: default 1000 pages (~4MB) causes large infrequent merges that spike
+ // write-lock hold time. 200 pages (~800KB) keeps each merge cheap while still amortizing I/O.
+ // Removed the blocking wal_checkpoint(PASSIVE) call (was 1-3s on large DBs); frequent small
+ // autocheckpoints are a better long-term strategy.
+ yield* db.run("PRAGMA wal_autocheckpoint = 200")
yield* DatabaseMigration.apply(db)
return { db }
diff --git a/packages/core/src/deepagent/deepagent-event-bus.ts b/packages/core/src/deepagent/deepagent-event-bus.ts
index 5f11eab5..f76d8eb9 100644
--- a/packages/core/src/deepagent/deepagent-event-bus.ts
+++ b/packages/core/src/deepagent/deepagent-event-bus.ts
@@ -276,8 +276,26 @@ export const layerWith = (options?: LayerOptions) =>
// K40-2: durable groups from DB (groups registered via registerConsumerGroup, survive restarts).
// Returns the union of live + durable groups, deduped. An offline group that registered durably
// will receive delivery rows even though it has no live stream right now.
- const groupsFor = (eventType: string): Effect.Effect> =>
- db
+ //
+ // PERF: cache the DB portion (durable groups) per eventType to avoid a DB round-trip on every
+ // publish. Consumer group membership changes only at startup (consumerRegistrationLayer) and on
+ // flag changes — both call registerConsumerGroup/unregisterConsumerGroup which invalidate the
+ // cache. Live groups (in-memory only) are always computed fresh; they change on subscribe/end.
+ const dbGroupsCache = new Map()
+ const invalidateDbGroupsCache = () => dbGroupsCache.clear()
+
+ const groupsFor = (eventType: string): Effect.Effect> => {
+ const cached = dbGroupsCache.get(eventType)
+ if (cached !== undefined) {
+ // DB portion is cached; still merge with current live groups (always fresh, no DB).
+ const live = liveGroupsFor(eventType)
+ if (live.length === 0) return Effect.succeed(cached)
+ const seen = new Set(cached)
+ const merged = [...cached]
+ for (const g of live) if (!seen.has(g)) merged.push(g)
+ return Effect.succeed(merged)
+ }
+ return db
.select({ group_id: DeepAgentConsumerGroupTable.group_id })
.from(DeepAgentConsumerGroupTable)
.where(
@@ -288,6 +306,7 @@ export const layerWith = (options?: LayerOptions) =>
Effect.orDie,
Effect.map((rows) => {
const dbGroups = rows.map((r) => r.group_id)
+ dbGroupsCache.set(eventType, dbGroups)
const live = liveGroupsFor(eventType)
// Union: db-registered + live-only (not yet durable-registered), deduplicated.
const seen = new Set(dbGroups)
@@ -296,6 +315,7 @@ export const layerWith = (options?: LayerOptions) =>
return merged
}),
)
+ }
const publish: Interface["publish"] = (input) =>
Effect.gen(function* () {
@@ -855,6 +875,7 @@ export const layerWith = (options?: LayerOptions) =>
// K40-2: durable consumer group registration — persists group identity so publish writes delivery
// rows for offline groups too. Both methods are idempotent; registerConsumerGroup upserts.
+ // PERF: both methods invalidate dbGroupsCache so the next groupsFor re-reads from DB.
const registerConsumerGroup: Interface["registerConsumerGroup"] = (groupId, typeFilter) => {
const at = now()
return db
@@ -865,7 +886,7 @@ export const layerWith = (options?: LayerOptions) =>
set: { type_filter: typeFilter ?? null, last_seen_at: at },
})
.run()
- .pipe(Effect.orDie, Effect.asVoid)
+ .pipe(Effect.orDie, Effect.asVoid, Effect.tap(() => Effect.sync(invalidateDbGroupsCache)))
}
const unregisterConsumerGroup: Interface["unregisterConsumerGroup"] = (groupId) =>
@@ -873,7 +894,7 @@ export const layerWith = (options?: LayerOptions) =>
.delete(DeepAgentConsumerGroupTable)
.where(eq(DeepAgentConsumerGroupTable.group_id, groupId))
.run()
- .pipe(Effect.orDie, Effect.asVoid)
+ .pipe(Effect.orDie, Effect.asVoid, Effect.tap(() => Effect.sync(invalidateDbGroupsCache)))
return Service.of({
publish,
diff --git a/packages/deepagent-code/src/session/event-dispatcher.ts b/packages/deepagent-code/src/session/event-dispatcher.ts
index e0a08700..be9f196d 100644
--- a/packages/deepagent-code/src/session/event-dispatcher.ts
+++ b/packages/deepagent-code/src/session/event-dispatcher.ts
@@ -513,7 +513,10 @@ export const layerWith = (options?: LayerOptions) =>
Effect.forkScoped,
)
// wait until the group is registered before the layer is considered ready.
- yield* Deferred.await(ready)
+ // Timeout guards against DB-stall (busy WAL/retention sweep): durable registration already
+ // happened via registerConsumerGroup above, so a brief live-stream miss is recoverable via
+ // the retry pump. 500ms is well above normal fiber-schedule latency (<1ms).
+ yield* Deferred.await(ready).pipe(Effect.timeout(Duration.millis(500)), Effect.ignore)
yield* tick()
.pipe(
diff --git a/packages/deepagent-code/src/session/goal-tick-consumer.ts b/packages/deepagent-code/src/session/goal-tick-consumer.ts
index 063c0703..85c48627 100644
--- a/packages/deepagent-code/src/session/goal-tick-consumer.ts
+++ b/packages/deepagent-code/src/session/goal-tick-consumer.ts
@@ -336,7 +336,10 @@ export const layerWith = (options: LayerOptions) =>
),
Effect.forkScoped,
)
- yield* Deferred.await(ready)
+ // Timeout guards against DB-stall (busy WAL/retention sweep): durable registration already
+ // happened via registerConsumerGroup above, so a brief live-stream miss is recoverable via
+ // the retry pump. 500ms is well above normal fiber-schedule latency (<1ms).
+ yield* Deferred.await(ready).pipe(Effect.timeout(Duration.millis(500)), Effect.ignore)
yield* pumpRetries()
.pipe(
From cb224bc1b0ed752c80b5bc87400874515a11f779 Mon Sep 17 00:00:00 2001
From: deepagent-ai
Date: Fri, 24 Jul 2026 10:43:18 +0800
Subject: [PATCH 07/22] =?UTF-8?q?feat(startup):=20eliminate=20home-page=20?=
=?UTF-8?q?flash=20=E2=80=94=20StartupSplashGate=20+=20intent=20snapshot?=
=?UTF-8?q?=20(r6)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Problem: D icon disappears → user sees home page loading → server sync completes
→ session page. The intermediate home-page state feels slow/broken.
Fix (4 coordinated changes):
1. packages/app/src/utils/startup-intent.ts [new]
Synchronous localStorage snapshot of last active session (server + directory +
sessionId + timestamp). Written on every navigateTab, read at startup in <0.1 ms
before any server request.
2. packages/app/src/context/tabs.tsx
navigateTab: call writeStartupIntent with base64Decode(tab.dirBase64) — fixes a
pre-existing atob() incompatibility with URL-safe base64 produced by base64Encode.
3. packages/app/src/App.tsx
StartupSplashGate: inserted inside RouterRoot, wraps AppShellProviders. Keeps the
D splash visible until onMount fires, then navigates directly to the intent session
(fresh intent < 30 s) and releases. Stale/absent intents pass through immediately
so new installs and long-idle cold starts are unaffected.
RouterRoot.createEffect: skip its own navigation while a fresh intent is active
(avoids a race between the gate and the tabs.ready() effect).
4. packages/app/src/context/global-sync/bootstrap.ts
bootstrapGlobal: split into fast path (config + projects — awaited) and slow path
(providers + path — background void). Cuts the blocking bootstrap from 4 parallel
requests to 2, halving the time until the sidebar and session routing are usable.
Slow-path errors are now logged via console.error instead of being silently dropped.
Adversarial review passed. Two CRITICAL issues from review were fixed before commit:
- atob → base64Decode (URL-safe base64 compatibility)
- removed fake SDK prefetch (result was not stored in QueryClient cache)
Ref: docs/4.0.4_r6.md
Co-Authored-By: Claude Opus 4.8 (1M context)
---
packages/app/src/app.tsx | 80 +++++++++++++++++--
.../app/src/context/global-sync/bootstrap.ts | 26 +++---
packages/app/src/context/tabs.tsx | 11 ++-
packages/app/src/utils/startup-intent.ts | 69 ++++++++++++++++
4 files changed, 169 insertions(+), 17 deletions(-)
create mode 100644 packages/app/src/utils/startup-intent.ts
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index dd00f9c7..73c77365 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -23,6 +23,7 @@ import {
type JSX,
lazy,
onCleanup,
+ onMount,
type ParentProps,
Show,
} from "solid-js"
@@ -52,6 +53,8 @@ import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
+import { readStartupIntent, INTENT_NAVIGATE_WINDOW_MS } from "@/utils/startup-intent"
+import { base64Encode } from "@deepagent-code/core/util/encode"
const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
@@ -140,6 +143,65 @@ function SessionProviders(props: ParentProps) {
)
}
+/**
+ * StartupSplashGate — keeps the D splash visible after ConnectionGate passes and
+ * navigates directly to the last session before revealing the app shell.
+ *
+ * Must be rendered inside a router root (needs useNavigate) and useServer.
+ * Reads the startup-intent snapshot written synchronously by navigateTab (tabs.tsx).
+ *
+ * Gate logic:
+ * • No intent / server mismatch → pass-through immediately (new install, cleared state).
+ * • Fresh intent (< INTENT_NAVIGATE_WINDOW_MS) → navigate to last session, release.
+ * • Stale intent (≥ window but < 24 h) → release immediately; the existing
+ * tabs.ready() effect in RouterRoot handles navigation as before.
+ *
+ * NOTE: The gate does NOT attempt to pre-warm the session query cache because the
+ * session SDK responses are keyed inside ServerSyncProvider (query key: [scope, dir,
+ * 'loadSessions']) which is not yet mounted when StartupSplashGate's onMount fires.
+ * The value of the gate is eliminating the visible home-page flash, not pre-fetching.
+ */
+function StartupSplashGate(props: ParentProps) {
+ const server = useServer()
+ const navigate = useNavigate()
+
+ const intent = readStartupIntent()
+
+ // No usable intent → skip the gate entirely (zero overhead).
+ if (!intent || intent.server !== server.key) {
+ return <>{props.children}>
+ }
+
+ const [warmupDone, setWarmupDone] = createSignal(false)
+
+ onMount(() => {
+ try {
+ // Navigate immediately when the intent is fresh (process crash / hot restart).
+ // Stale intents delegate to the tabs.ready() effect so a long-idle cold-start
+ // doesn't force navigation to a session the user may no longer want.
+ if (Date.now() - intent.at < INTENT_NAVIGATE_WINDOW_MS) {
+ const dirBase64 = base64Encode(intent.directory)
+ navigate(`/${dirBase64}/session/${intent.sessionId}`, { replace: true })
+ }
+ } finally {
+ setWarmupDone(true)
+ }
+ })
+
+ return (
+
+
+
+ }
+ >
+ {props.children}
+
+ )
+}
+
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
const tabs = useTabs()
const server = useServer()
@@ -148,9 +210,13 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
// Restore the explicitly persisted active tab. A cross-server directory is never
// navigated until the target server has become active.
+ // Skip when StartupSplashGate has a fresh intent — it navigates directly and this
+ // effect would race/conflict with it during the warmup window.
createEffect(() => {
if (!tabs.ready()) return
if (location.pathname !== "/") return
+ const intent = readStartupIntent()
+ if (intent && intent.server === server.key && Date.now() - intent.at < INTENT_NAVIGATE_WINDOW_MS) return
const tab = startupTab(tabs.store, tabs.active.key, server.list)
if (!tab) return
if (server.key !== tab.server) {
@@ -161,12 +227,14 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
})
return (
-
- {/*}>*/}
- {props.appChildren}
- {props.children}
- {/**/}
-
+
+
+ {/*}>*/}
+ {props.appChildren}
+ {props.children}
+ {/**/}
+
+
)
}
diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts
index 8792e665..67503830 100644
--- a/packages/app/src/context/global-sync/bootstrap.ts
+++ b/packages/app/src/context/global-sync/bootstrap.ts
@@ -127,22 +127,28 @@ export async function bootstrapGlobal(input: {
setGlobalStore: SetStoreFunction
queryClient: QueryClient
}) {
- const slow = [
+ // FAST PATH — minimum data needed before the first session renders.
+ // config: gates feature flags; project list: gates sidebar + project routing.
+ // Both are awaited so the UI is never shown with a structurally incomplete store.
+ const fast = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
- () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
- () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
.then((data) => input.setGlobalStore("project", data)),
]
- await runAll(slow)
- // showErrors({
- // errors: errors(),
- // title: input.requestFailedTitle,
- // translate: input.translate,
- // formatMoreCount: input.formatMoreCount,
- // })
+ await runAll(fast)
+
+ // SLOW PATH — secondary data that enriches the UI but does not block rendering.
+ // Providers and path are not needed for the session list or initial navigation;
+ // load them in the background so they become available shortly after first paint.
+ void runAll([
+ () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
+ () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
+ ]).then((results) => {
+ const errs = errors(results).filter((e) => !isCancellation(e))
+ if (errs.length > 0) console.error("[bootstrapGlobal] slow-path failed:", errs[0])
+ })
}
function groupBySession(input: T[]) {
diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx
index 64ac3e46..6ef98899 100644
--- a/packages/app/src/context/tabs.tsx
+++ b/packages/app/src/context/tabs.tsx
@@ -1,12 +1,13 @@
import type { Session } from "@deepagent-code/sdk/v2/client"
import { createSimpleContext } from "@deepagent-code/ui/context"
-import { base64Encode } from "@deepagent-code/core/util/encode"
+import { base64Encode, base64Decode } from "@deepagent-code/core/util/encode"
import { createStore, produce } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { ServerConnection, useServer } from "./server"
import { createEffect, startTransition } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
+import { writeStartupIntent } from "@/utils/startup-intent"
export type SessionTab = {
type: "session"
@@ -75,6 +76,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
})
const navigateTab = (tab: Tab) => {
+ // Persist intent so the next launch can pre-warm this session before
+ // showing the UI (StartupSplashGate reads this synchronously on mount).
+ writeStartupIntent({
+ server: tab.server,
+ directory: base64Decode(tab.dirBase64),
+ sessionId: tab.sessionId,
+ at: Date.now(),
+ })
setActive("key", tabKey(tab))
const href = tabHref(tab)
if (tab.server === server.key) {
diff --git a/packages/app/src/utils/startup-intent.ts b/packages/app/src/utils/startup-intent.ts
new file mode 100644
index 00000000..d209525f
--- /dev/null
+++ b/packages/app/src/utils/startup-intent.ts
@@ -0,0 +1,69 @@
+/**
+ * Startup intent snapshot — persists the user's last active session to localStorage
+ * so the renderer can navigate directly to it on next launch without waiting for
+ * server-side persisted state to load.
+ *
+ * Written synchronously every time the user navigates to a session tab.
+ * Read synchronously at startup (before any server request).
+ */
+
+const STARTUP_INTENT_KEY = "deepagent:startup-intent"
+/** Ignore intents older than 24 h (user may have switched projects externally). */
+const INTENT_MAX_AGE_MS = 24 * 60 * 60 * 1000
+
+export type StartupIntent = {
+ /** ServerConnection.Key — e.g. "local" or "server:" */
+ server: string
+ /** Absolute filesystem path of the project directory */
+ directory: string
+ /** Session ID to restore (may be stale; the session page will handle that gracefully) */
+ sessionId: string
+ /** Unix timestamp (ms) when this intent was written */
+ at: number
+}
+
+/**
+ * Persist the user's current session as the startup intent.
+ * Synchronous — safe to call on every tab navigation (< 0.1 ms).
+ */
+export function writeStartupIntent(intent: StartupIntent): void {
+ try {
+ localStorage.setItem(STARTUP_INTENT_KEY, JSON.stringify(intent))
+ } catch {
+ // localStorage unavailable (private browsing, quota, etc.) — silently ignore
+ }
+}
+
+/**
+ * Read the startup intent written by a previous session.
+ * Returns null if none exists, it is malformed, or it is older than 24 h.
+ * Synchronous — safe to call during component initialisation.
+ */
+export function readStartupIntent(): StartupIntent | null {
+ try {
+ const raw = localStorage.getItem(STARTUP_INTENT_KEY)
+ if (!raw) return null
+ const parsed = JSON.parse(raw) as Partial
+ if (
+ typeof parsed.server !== "string" ||
+ typeof parsed.directory !== "string" ||
+ typeof parsed.sessionId !== "string" ||
+ typeof parsed.at !== "number"
+ ) {
+ return null
+ }
+ if (Date.now() - parsed.at > INTENT_MAX_AGE_MS) return null
+ return parsed as StartupIntent
+ } catch {
+ return null
+ }
+}
+
+/**
+ * How fresh an intent must be for the StartupSplashGate to honour it for
+ * immediate navigation (separate from the 24 h existence window). If the app
+ * was last used > 30 s ago the gate still preloads but uses the tabs fallback
+ * for the final navigate (avoids navigating to a stale session after e.g. a
+ * reboot). The 24 h window keeps the intent available for warmup preloading.
+ */
+export const INTENT_NAVIGATE_WINDOW_MS = 30_000
From 7d02f5d6f80a0821bda063f6307da20999592d39 Mon Sep 17 00:00:00 2001
From: deepagent-ai
Date: Fri, 24 Jul 2026 11:01:12 +0800
Subject: [PATCH 08/22] =?UTF-8?q?fix(startup):=20D=20disappears=20?=
=?UTF-8?q?=E2=86=92=20direct=20session,=20no=20home=20flash=20=E2=80=94?=
=?UTF-8?q?=20real=20fix=20(r6=20v2)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Root cause: StartupSplashGate was inside AppInterface which only mounts AFTER
the D splash (controlled by ready() in index.tsx) already disappears. The gate
was in the wrong activation point — it ran after the flash, not before.
Real fix: prime MemoryHistory before AppInterface mounts.
packages/desktop/src/renderer/index.tsx:
- Read startup intent synchronously when ready() first becomes true (= server
ready, before any component mounts).
- Call createMemoryHistory() then history.set({value: sessionPath}) so the
router's initial location is the session URL, not '/'.
- Pass the pre-seeded history to MemoryRouter via the router prop of AppInterface.
- AppInterface now mounts with the router already at the session page; the home
route (HomeRoute) is never rendered, eliminating the visible flash.
- Inline readLocalStartupIntent + encodeBase64Url to avoid cross-package
compiled-artifact resolution issues.
packages/app/src/App.tsx (StartupSplashGate):
- Add location.pathname !== '/' early return so the gate is a no-op when the
router already started at the session URL (avoids spurious one-frame splash).
packages/app/src/index.ts:
- Export readStartupIntent + INTENT_NAVIGATE_WINDOW_MS (for future use).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
packages/app/src/app.tsx | 6 ++-
packages/app/src/index.ts | 1 +
packages/desktop/src/renderer/index.tsx | 59 ++++++++++++++++++++++---
3 files changed, 58 insertions(+), 8 deletions(-)
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 73c77365..d736e76b 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -164,11 +164,13 @@ function SessionProviders(props: ParentProps) {
function StartupSplashGate(props: ParentProps) {
const server = useServer()
const navigate = useNavigate()
+ const location = useLocation()
const intent = readStartupIntent()
- // No usable intent → skip the gate entirely (zero overhead).
- if (!intent || intent.server !== server.key) {
+ // No usable intent, server mismatch, or router already at the session URL
+ // (index.tsx set MemoryRouter's initialEntries to the session path) → pass through.
+ if (!intent || intent.server !== server.key || location.pathname !== "/") {
return <>{props.children}>
}
diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts
index 46bc70e4..65b486a4 100644
--- a/packages/app/src/index.ts
+++ b/packages/app/src/index.ts
@@ -21,3 +21,4 @@ export {
} from "./wsl/types"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click"
+export { readStartupIntent, INTENT_NAVIGATE_WINDOW_MS } from "./utils/startup-intent"
diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx
index 8ff32622..c1d2633a 100644
--- a/packages/desktop/src/renderer/index.tsx
+++ b/packages/desktop/src/renderer/index.tsx
@@ -17,7 +17,8 @@ import {
import type { UpdaterState } from "@deepagent-code/app/updater"
import * as Sentry from "@sentry/solid"
import type { AsyncStorage } from "@solid-primitives/storage"
-import { MemoryRouter } from "@solidjs/router"
+import { MemoryRouter, createMemoryHistory } from "@solidjs/router"
+import type { BaseRouterProps } from "@solidjs/router"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../../package.json"
@@ -29,6 +30,32 @@ import "./styles.css"
import { Splash } from "@deepagent-code/ui/logo"
import { useTheme } from "@deepagent-code/ui/theme/context"
+// Inline startup-intent reader — mirrors packages/app/src/utils/startup-intent.ts.
+// Inlined here to avoid cross-package compiled-artifact resolution issues.
+const STARTUP_INTENT_KEY = "deepagent:startup-intent"
+const INTENT_NAVIGATE_WINDOW_MS = 30_000
+
+function readLocalStartupIntent() {
+ try {
+ const raw = localStorage.getItem(STARTUP_INTENT_KEY)
+ if (!raw) return null
+ const p = JSON.parse(raw) as Record
+ if (
+ typeof p.server !== "string" ||
+ typeof p.directory !== "string" ||
+ typeof p.sessionId !== "string" ||
+ typeof p.at !== "number"
+ ) return null
+ if (Date.now() - p.at > INTENT_NAVIGATE_WINDOW_MS) return null
+ return p as { server: string; directory: string; sessionId: string; at: number }
+ } catch { return null }
+}
+
+/** URL-safe base64 encode (matches base64Encode from @deepagent-code/core/util/encode). */
+function encodeBase64Url(value: string): string {
+ return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
+}
+
// renderer.initialization — start: renderer module begins executing.
// Captured at module level so it includes all synchronous setup before render().
const rendererStartTime = performance.now()
@@ -381,11 +408,31 @@ render(() => {
return (
- {(key) => (
-
-
-
- )}
+ {(key) => {
+ // Read the startup intent synchronously at the moment ready() first becomes
+ // true. If it is fresh (< INTENT_NAVIGATE_WINDOW_MS) and the server matches,
+ // prime MemoryHistory to start at the session URL so AppInterface never
+ // renders the home route — eliminating the visible home-page flash.
+ const intent = readLocalStartupIntent()
+ const history = createMemoryHistory()
+ if (intent && intent.server === key) {
+ const dirBase64 = encodeBase64Url(intent.directory)
+ history.set({
+ value: `/${dirBase64}/session/${intent.sessionId}`,
+ replace: true,
+ })
+ }
+
+ const startupRouter = (props: BaseRouterProps) => (
+
+ )
+
+ return (
+
+
+
+ )
+ }}
)
From 10f250d580d7824f0a07df591d1188008375aec9 Mon Sep 17 00:00:00 2001
From: deepagent-ai
Date: Fri, 24 Jul 2026 11:01:42 +0800
Subject: [PATCH 09/22] fix(sidebar): stabilize For loop keys to prevent mass
SortableProject remount
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Root cause of both the 3-second project-switch lag AND the recurring DnD
'Cannot remove nonexistent' errors:
layout.projects.list() runs a .map() that creates new object references on
every memo invocation. SolidJS For<> tracks items by VALUE IDENTITY (===),
so new object references = all items are 'new' = destroy+recreate every
SortableProject component on each update:
1. Every SortableWorkspace's serverSync.child(bootstrap:true) fires at once
→ simultaneous API calls → 3+ second apparent freeze
2. createSortable onCleanup fires for all items while DragDropProvider is
still alive but mid-reconciliation → 'Cannot remove nonexistent' DnD errors
3. After enough cycles, stale DnD sensor state blocks subsequent clicks
Fix: iterate For<> over worktree STRINGS (stable primitives, same path ===
same identity) instead of project objects. The project data is looked up
reactively inside each slot via createMemo(). SortableProject components now
stay alive across icon-color updates and project navigations — only actually
added/removed projects cause mount/unmount.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../app/src/pages/layout/sidebar-shell.tsx | 27 ++++++++++++++++++-
1 file changed, 26 insertions(+), 1 deletion(-)
diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx
index b83caf53..429037f7 100644
--- a/packages/app/src/pages/layout/sidebar-shell.tsx
+++ b/packages/app/src/pages/layout/sidebar-shell.tsx
@@ -75,7 +75,32 @@ export const SidebarContent = (props: {
p.worktree)}>
- {(project) => props.renderProject(project)}
+ {/*
+ * Iterate over stable worktree STRINGS, not project objects.
+ *
+ * layout.projects.list() creates new object references on every
+ * memo run (via .map({ ...project, icon })). Using those objects
+ * as For keys causes SolidJS to destroy+recreate EVERY SortableProject
+ * on each icon-color update or navigation, which:
+ * 1. Mass-fires DnD cleanup before DragDropProvider is gone → errors
+ * 2. Triggers every SortableWorkspace's bootstrap API call at once → 3s lag
+ *
+ * Worktree strings are stable primitives (same path === same identity).
+ * The project data updates reactively inside each component via the
+ * `projectFor` accessor below.
+ */}
+ p.worktree)}>
+ {(worktree) => {
+ const project = createMemo(
+ () => props.projects().find((p) => p.worktree === worktree),
+ )
+ return (
+
+ {(p) => props.renderProject(p)}
+
+ )
+ }}
+
Date: Fri, 24 Jul 2026 13:36:13 +0800
Subject: [PATCH 10/22] fix(desktop): wait for restored session before reveal
---
packages/app/src/app.tsx | 176 +++++++++----------
packages/app/src/context/tabs.tsx | 11 +-
packages/app/src/index.ts | 1 -
packages/app/src/pages/layout.tsx | 7 +-
packages/app/src/utils/startup-intent.ts | 69 --------
packages/app/src/utils/startup-ready.test.ts | 48 +++++
packages/app/src/utils/startup-ready.ts | 20 +++
packages/desktop/src/renderer/index.tsx | 127 +++++++------
8 files changed, 229 insertions(+), 230 deletions(-)
delete mode 100644 packages/app/src/utils/startup-intent.ts
create mode 100644 packages/app/src/utils/startup-ready.test.ts
create mode 100644 packages/app/src/utils/startup-ready.ts
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index d736e76b..57dead0e 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -9,7 +9,7 @@ import { Font } from "@deepagent-code/ui/font"
import { Splash } from "@deepagent-code/ui/logo"
import { ThemeProvider } from "@deepagent-code/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
-import { type BaseRouterProps, Navigate, Route, Router, useLocation, useNavigate } from "@solidjs/router"
+import { type BaseRouterProps, Navigate, Route, Router, useLocation, useParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
@@ -23,7 +23,6 @@ import {
type JSX,
lazy,
onCleanup,
- onMount,
type ParentProps,
Show,
} from "solid-js"
@@ -35,7 +34,7 @@ import { FileProvider } from "@/context/file"
import type { DesktopApi } from "@/utils/desktop-api"
import { GatewayProvider } from "@/context/gateway"
import { ServerSDKProvider } from "@/context/server-sdk"
-import { ServerSyncProvider } from "@/context/server-sync"
+import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
import { GlobalProvider } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
@@ -47,14 +46,13 @@ import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
-import { startupTab, TabsProvider, useTabs } from "@/context/tabs"
+import { TabsProvider } from "@/context/tabs"
import { WslServersProvider } from "@/wsl/context"
-import DirectoryLayout from "@/pages/directory-layout"
+import DirectoryLayout, { decodeDirectory } from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
-import { readStartupIntent, INTENT_NAVIGATE_WINDOW_MS } from "@/utils/startup-intent"
-import { base64Encode } from "@deepagent-code/core/util/encode"
+import { startupViewReady } from "@/utils/startup-ready"
const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
@@ -108,7 +106,9 @@ function BodyDesignClass() {
return null
}
-function AppShellProviders(props: ParentProps) {
+function AppShellProviders(props: ParentProps<{ onStartupReady?: () => void }>) {
+ const [startupRestoreSettled, setStartupRestoreSettled] = createSignal(false)
+
return (
@@ -118,7 +118,12 @@ function AppShellProviders(props: ParentProps) {
- {props.children}
+ setStartupRestoreSettled(true)}>
+ {props.onStartupReady ? (
+
+ ) : null}
+ {props.children}
+
@@ -143,100 +148,74 @@ function SessionProviders(props: ParentProps) {
)
}
-/**
- * StartupSplashGate — keeps the D splash visible after ConnectionGate passes and
- * navigates directly to the last session before revealing the app shell.
- *
- * Must be rendered inside a router root (needs useNavigate) and useServer.
- * Reads the startup-intent snapshot written synchronously by navigateTab (tabs.tsx).
- *
- * Gate logic:
- * • No intent / server mismatch → pass-through immediately (new install, cleared state).
- * • Fresh intent (< INTENT_NAVIGATE_WINDOW_MS) → navigate to last session, release.
- * • Stale intent (≥ window but < 24 h) → release immediately; the existing
- * tabs.ready() effect in RouterRoot handles navigation as before.
- *
- * NOTE: The gate does NOT attempt to pre-warm the session query cache because the
- * session SDK responses are keyed inside ServerSyncProvider (query key: [scope, dir,
- * 'loadSessions']) which is not yet mounted when StartupSplashGate's onMount fires.
- * The value of the gate is eliminating the visible home-page flash, not pre-fetching.
- */
-function StartupSplashGate(props: ParentProps) {
+function StartupViewReady(props: { restoreSettled: boolean; onReady: () => void }) {
const server = useServer()
- const navigate = useNavigate()
+ const serverSync = useServerSync()
const location = useLocation()
+ const params = useParams()
+ let complete = false
+ let signature = ""
- const intent = readStartupIntent()
-
- // No usable intent, server mismatch, or router already at the session URL
- // (index.tsx set MemoryRouter's initialEntries to the session path) → pass through.
- if (!intent || intent.server !== server.key || location.pathname !== "/") {
- return <>{props.children}>
+ const state = () => {
+ const directory = params.dir ? decodeDirectory(params.dir) : undefined
+ const sessionId = params.id
+ const store = directory ? serverSync.peek(directory, { bootstrap: false })[0] : undefined
+ return {
+ pathname: location.pathname,
+ serverReady: server.ready(),
+ globalReady: serverSync.ready,
+ globalError: !!serverSync.error,
+ restoreSettled: props.restoreSettled,
+ lastProject: server.projects.last(),
+ directory,
+ directoryReady: !directory || (!!store && store.status !== "loading"),
+ sessionId,
+ hasSession: !!store?.session.some((session) => session.id === sessionId),
+ messagesReady: !!sessionId && store?.message[sessionId] !== undefined,
+ }
}
- const [warmupDone, setWarmupDone] = createSignal(false)
-
- onMount(() => {
- try {
- // Navigate immediately when the intent is fresh (process crash / hot restart).
- // Stale intents delegate to the tabs.ready() effect so a long-idle cold-start
- // doesn't force navigation to a session the user may no longer want.
- if (Date.now() - intent.at < INTENT_NAVIGATE_WINDOW_MS) {
- const dirBase64 = base64Encode(intent.directory)
- navigate(`/${dirBase64}/session/${intent.sessionId}`, { replace: true })
- }
- } finally {
- setWarmupDone(true)
+ createEffect(() => {
+ const current = state()
+ const nextSignature = JSON.stringify({
+ route: current.pathname === "/" ? "home" : current.sessionId ? "session" : "directory",
+ serverReady: current.serverReady,
+ globalReady: current.globalReady,
+ globalError: current.globalError,
+ restoreSettled: current.restoreSettled,
+ hasLastProject: !!current.lastProject,
+ hasDirectory: !!current.directory,
+ directoryReady: current.directoryReady,
+ hasSessionId: !!current.sessionId,
+ hasSession: current.hasSession,
+ messagesReady: current.messagesReady,
+ })
+ if (signature !== nextSignature) {
+ signature = nextSignature
+ console.info("[startup] readiness", nextSignature)
}
+
+ if (complete || !startupViewReady(current)) return
+ complete = true
+ props.onReady()
})
- return (
-
-
-
- }
- >
- {props.children}
-
- )
+ return null
}
-function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
- const tabs = useTabs()
- const server = useServer()
- const navigate = useNavigate()
- const location = useLocation()
-
- // Restore the explicitly persisted active tab. A cross-server directory is never
- // navigated until the target server has become active.
- // Skip when StartupSplashGate has a fresh intent — it navigates directly and this
- // effect would race/conflict with it during the warmup window.
- createEffect(() => {
- if (!tabs.ready()) return
- if (location.pathname !== "/") return
- const intent = readStartupIntent()
- if (intent && intent.server === server.key && Date.now() - intent.at < INTENT_NAVIGATE_WINDOW_MS) return
- const tab = startupTab(tabs.store, tabs.active.key, server.list)
- if (!tab) return
- if (server.key !== tab.server) {
- server.setActive(tab.server)
- return
- }
- navigate(`/${tab.dirBase64}/session`, { replace: true })
- })
-
+function RouterRoot(
+ props: ParentProps<{
+ appChildren?: JSX.Element
+ onStartupReady?: () => void
+ }>,
+) {
return (
-
-
- {/*}>*/}
- {props.appChildren}
- {props.children}
- {/**/}
-
-
+
+ {/*}>*/}
+ {props.appChildren}
+ {props.children}
+ {/**/}
+
)
}
@@ -397,6 +376,7 @@ export function AppInterface(props: {
servers?: Array
router?: Component
disableHealthCheck?: boolean
+ onStartupReady?: () => void
}) {
return (
@@ -415,7 +395,9 @@ export function AppInterface(props: {
- {routerProps.children}
+
+ {routerProps.children}
+
@@ -425,10 +407,10 @@ export function AppInterface(props: {
>
- } />
-
-
-
+ } />
+
+
+
diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx
index 6ef98899..64ac3e46 100644
--- a/packages/app/src/context/tabs.tsx
+++ b/packages/app/src/context/tabs.tsx
@@ -1,13 +1,12 @@
import type { Session } from "@deepagent-code/sdk/v2/client"
import { createSimpleContext } from "@deepagent-code/ui/context"
-import { base64Encode, base64Decode } from "@deepagent-code/core/util/encode"
+import { base64Encode } from "@deepagent-code/core/util/encode"
import { createStore, produce } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { ServerConnection, useServer } from "./server"
import { createEffect, startTransition } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
-import { writeStartupIntent } from "@/utils/startup-intent"
export type SessionTab = {
type: "session"
@@ -76,14 +75,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
})
const navigateTab = (tab: Tab) => {
- // Persist intent so the next launch can pre-warm this session before
- // showing the UI (StartupSplashGate reads this synchronously on mount).
- writeStartupIntent({
- server: tab.server,
- directory: base64Decode(tab.dirBase64),
- sessionId: tab.sessionId,
- at: Date.now(),
- })
setActive("key", tabKey(tab))
const href = tabHref(tab)
if (tab.server === server.key) {
diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts
index 65b486a4..46bc70e4 100644
--- a/packages/app/src/index.ts
+++ b/packages/app/src/index.ts
@@ -21,4 +21,3 @@ export {
} from "./wsl/types"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click"
-export { readStartupIntent, INTENT_NAVIGATE_WINDOW_MS } from "./utils/startup-intent"
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 42a325f1..55705fdb 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -95,7 +95,7 @@ import {
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell"
-export default function Layout(props: ParentProps) {
+export default function Layout(props: ParentProps<{ onStartupRestoreSettled?: () => void }>) {
const serverSDK = useServerSDK()
const [store, setStore, , ready] = persisted(
Persist.serverGlobal(serverSDK.scope, "layout.page", ["layout.page.v1"]),
@@ -601,6 +601,11 @@ export default function Layout(props: ParentProps) {
}
})
+ createEffect(() => {
+ if (autoselecting.loading) return
+ props.onStartupRestoreSettled?.()
+ })
+
const workspaceName = (directory: string, projectId?: string, branch?: string) => {
const key = pathKey(directory)
const direct = store.workspaceName[key] ?? store.workspaceName[directory]
diff --git a/packages/app/src/utils/startup-intent.ts b/packages/app/src/utils/startup-intent.ts
deleted file mode 100644
index d209525f..00000000
--- a/packages/app/src/utils/startup-intent.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * Startup intent snapshot — persists the user's last active session to localStorage
- * so the renderer can navigate directly to it on next launch without waiting for
- * server-side persisted state to load.
- *
- * Written synchronously every time the user navigates to a session tab.
- * Read synchronously at startup (before any server request).
- */
-
-const STARTUP_INTENT_KEY = "deepagent:startup-intent"
-/** Ignore intents older than 24 h (user may have switched projects externally). */
-const INTENT_MAX_AGE_MS = 24 * 60 * 60 * 1000
-
-export type StartupIntent = {
- /** ServerConnection.Key — e.g. "local" or "server:" */
- server: string
- /** Absolute filesystem path of the project directory */
- directory: string
- /** Session ID to restore (may be stale; the session page will handle that gracefully) */
- sessionId: string
- /** Unix timestamp (ms) when this intent was written */
- at: number
-}
-
-/**
- * Persist the user's current session as the startup intent.
- * Synchronous — safe to call on every tab navigation (< 0.1 ms).
- */
-export function writeStartupIntent(intent: StartupIntent): void {
- try {
- localStorage.setItem(STARTUP_INTENT_KEY, JSON.stringify(intent))
- } catch {
- // localStorage unavailable (private browsing, quota, etc.) — silently ignore
- }
-}
-
-/**
- * Read the startup intent written by a previous session.
- * Returns null if none exists, it is malformed, or it is older than 24 h.
- * Synchronous — safe to call during component initialisation.
- */
-export function readStartupIntent(): StartupIntent | null {
- try {
- const raw = localStorage.getItem(STARTUP_INTENT_KEY)
- if (!raw) return null
- const parsed = JSON.parse(raw) as Partial
- if (
- typeof parsed.server !== "string" ||
- typeof parsed.directory !== "string" ||
- typeof parsed.sessionId !== "string" ||
- typeof parsed.at !== "number"
- ) {
- return null
- }
- if (Date.now() - parsed.at > INTENT_MAX_AGE_MS) return null
- return parsed as StartupIntent
- } catch {
- return null
- }
-}
-
-/**
- * How fresh an intent must be for the StartupSplashGate to honour it for
- * immediate navigation (separate from the 24 h existence window). If the app
- * was last used > 30 s ago the gate still preloads but uses the tabs fallback
- * for the final navigate (avoids navigating to a stale session after e.g. a
- * reboot). The 24 h window keeps the intent available for warmup preloading.
- */
-export const INTENT_NAVIGATE_WINDOW_MS = 30_000
diff --git a/packages/app/src/utils/startup-ready.test.ts b/packages/app/src/utils/startup-ready.test.ts
new file mode 100644
index 00000000..d31bb59a
--- /dev/null
+++ b/packages/app/src/utils/startup-ready.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, test } from "bun:test"
+import { startupViewReady } from "./startup-ready"
+
+const ready = {
+ pathname: "/project/session/session-1",
+ serverReady: true,
+ globalReady: true,
+ globalError: false,
+ restoreSettled: true,
+ directory: "/workspace/project",
+ directoryReady: true,
+ sessionId: "session-1",
+ hasSession: true,
+ messagesReady: true,
+}
+
+describe("startup view readiness", () => {
+ test("keeps the home route covered while the persisted project is restoring", () => {
+ expect(
+ startupViewReady({
+ ...ready,
+ pathname: "/",
+ directory: undefined,
+ sessionId: undefined,
+ restoreSettled: false,
+ }),
+ ).toBe(false)
+ expect(startupViewReady({ ...ready, pathname: "/", directory: undefined, sessionId: undefined })).toBe(true)
+ })
+
+ test("waits for global and directory synchronization", () => {
+ expect(startupViewReady({ ...ready, serverReady: false })).toBe(false)
+ expect(startupViewReady({ ...ready, globalReady: false })).toBe(false)
+ expect(startupViewReady({ ...ready, restoreSettled: false })).toBe(false)
+ expect(startupViewReady({ ...ready, directoryReady: false })).toBe(false)
+ })
+
+ test("requires restored session metadata and initial messages", () => {
+ expect(startupViewReady(ready)).toBe(true)
+ expect(startupViewReady({ ...ready, hasSession: false })).toBe(false)
+ expect(startupViewReady({ ...ready, messagesReady: false })).toBe(false)
+ })
+
+ test("allows a synchronized new-session route and server error state", () => {
+ expect(startupViewReady({ ...ready, sessionId: undefined })).toBe(true)
+ expect(startupViewReady({ ...ready, globalReady: false, globalError: true })).toBe(true)
+ })
+})
diff --git a/packages/app/src/utils/startup-ready.ts b/packages/app/src/utils/startup-ready.ts
new file mode 100644
index 00000000..24fb7569
--- /dev/null
+++ b/packages/app/src/utils/startup-ready.ts
@@ -0,0 +1,20 @@
+export function startupViewReady(input: {
+ pathname: string
+ serverReady: boolean
+ globalReady: boolean
+ globalError: boolean
+ restoreSettled: boolean
+ directory?: string
+ directoryReady: boolean
+ sessionId?: string
+ hasSession: boolean
+ messagesReady: boolean
+}) {
+ if (input.globalError) return true
+ if (!input.serverReady || !input.globalReady || !input.restoreSettled) return false
+ if (input.pathname === "/") return true
+ if (!input.directory) return true
+ if (!input.directoryReady) return false
+ if (!input.sessionId) return true
+ return input.hasSession && input.messagesReady
+}
diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx
index c1d2633a..33b95d1d 100644
--- a/packages/desktop/src/renderer/index.tsx
+++ b/packages/desktop/src/renderer/index.tsx
@@ -17,8 +17,7 @@ import {
import type { UpdaterState } from "@deepagent-code/app/updater"
import * as Sentry from "@sentry/solid"
import type { AsyncStorage } from "@solid-primitives/storage"
-import { MemoryRouter, createMemoryHistory } from "@solidjs/router"
-import type { BaseRouterProps } from "@solidjs/router"
+import { type BaseRouterProps, MemoryRouter, createMemoryHistory } from "@solidjs/router"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../../package.json"
@@ -30,31 +29,8 @@ import "./styles.css"
import { Splash } from "@deepagent-code/ui/logo"
import { useTheme } from "@deepagent-code/ui/theme/context"
-// Inline startup-intent reader — mirrors packages/app/src/utils/startup-intent.ts.
-// Inlined here to avoid cross-package compiled-artifact resolution issues.
-const STARTUP_INTENT_KEY = "deepagent:startup-intent"
-const INTENT_NAVIGATE_WINDOW_MS = 30_000
-
-function readLocalStartupIntent() {
- try {
- const raw = localStorage.getItem(STARTUP_INTENT_KEY)
- if (!raw) return null
- const p = JSON.parse(raw) as Record
- if (
- typeof p.server !== "string" ||
- typeof p.directory !== "string" ||
- typeof p.sessionId !== "string" ||
- typeof p.at !== "number"
- ) return null
- if (Date.now() - p.at > INTENT_NAVIGATE_WINDOW_MS) return null
- return p as { server: string; directory: string; sessionId: string; at: number }
- } catch { return null }
-}
-
-/** URL-safe base64 encode (matches base64Encode from @deepagent-code/core/util/encode). */
-function encodeBase64Url(value: string): string {
- return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
-}
+const STARTUP_SLOW_MS = 15_000
+const STARTUP_HARD_TIMEOUT_MS = 120_000
// renderer.initialization — start: renderer module begins executing.
// Captured at module level so it includes all synchronous setup before render().
@@ -405,34 +381,81 @@ render(() => {
ServerConnection.Key.make(availableStartupServer(defaultServer.latest, wslServers.data)),
)
+ function RoutedApp(props: { serverKey: ServerConnection.Key }) {
+ const history = createMemoryHistory()
+ const startupRouter = (props: BaseRouterProps) =>
+ const [startupState, setStartupState] = createSignal<"waiting" | "ready" | "timeout">("waiting")
+ const startupStartedAt = performance.now()
+ let slowTimeout: number | undefined
+ let hardTimeout: number | undefined
+
+ const finishStartup = (result: "ready" | "timeout") => {
+ if (startupState() !== "waiting") return
+ if (slowTimeout !== undefined) window.clearTimeout(slowTimeout)
+ if (hardTimeout !== undefined) window.clearTimeout(hardTimeout)
+ slowTimeout = undefined
+ hardTimeout = undefined
+ const payload = {
+ event: "startup.app_reveal",
+ result,
+ durationMs: Math.round(performance.now() - startupStartedAt),
+ }
+ if (result === "timeout") console.warn("[startup] telemetry", JSON.stringify(payload))
+ if (result === "ready") console.info("[startup] telemetry", JSON.stringify(payload))
+ setStartupState(result)
+ }
+
+ onMount(() => {
+ slowTimeout = window.setTimeout(() => {
+ if (startupState() !== "waiting") return
+ console.warn(
+ "[startup] telemetry",
+ JSON.stringify({
+ event: "startup.app_reveal",
+ result: "slow",
+ durationMs: Math.round(performance.now() - startupStartedAt),
+ }),
+ )
+ }, STARTUP_SLOW_MS)
+ hardTimeout = window.setTimeout(() => finishStartup("timeout"), STARTUP_HARD_TIMEOUT_MS)
+ onCleanup(() => {
+ if (slowTimeout !== undefined) window.clearTimeout(slowTimeout)
+ if (hardTimeout !== undefined) window.clearTimeout(hardTimeout)
+ })
+ })
+
+ const waiting = () => startupState() === "waiting"
+
+ return (
+ <>
+
+ finishStartup("ready")}
+ >
+
+
+
+
+
+
+
+
+ >
+ )
+ }
+
return (
- {(key) => {
- // Read the startup intent synchronously at the moment ready() first becomes
- // true. If it is fresh (< INTENT_NAVIGATE_WINDOW_MS) and the server matches,
- // prime MemoryHistory to start at the session URL so AppInterface never
- // renders the home route — eliminating the visible home-page flash.
- const intent = readLocalStartupIntent()
- const history = createMemoryHistory()
- if (intent && intent.server === key) {
- const dirBase64 = encodeBase64Url(intent.directory)
- history.set({
- value: `/${dirBase64}/session/${intent.sessionId}`,
- replace: true,
- })
- }
-
- const startupRouter = (props: BaseRouterProps) => (
-
- )
-
- return (
-
-
-
- )
- }}
+ {(key) => }
)
From 18744862a9a4153f6dd7743a8546751cf77b1290 Mon Sep 17 00:00:00 2001
From: deepagent-ai
Date: Fri, 24 Jul 2026 16:16:54 +0800
Subject: [PATCH 11/22] fix(session): contain unfinished runner and define
prompt epochs
---
.../test/server/httpapi-session.test.ts | 25 +++++++++++++++++--
packages/server/src/groups/session.ts | 5 ++--
packages/server/src/handlers.ts | 2 --
packages/server/src/handlers/session.ts | 18 ++++++++++++-
4 files changed, 43 insertions(+), 7 deletions(-)
diff --git a/packages/deepagent-code/test/server/httpapi-session.test.ts b/packages/deepagent-code/test/server/httpapi-session.test.ts
index 70b9b6b9..aa685aef 100644
--- a/packages/deepagent-code/test/server/httpapi-session.test.ts
+++ b/packages/deepagent-code/test/server/httpapi-session.test.ts
@@ -606,7 +606,7 @@ describe("session HttpApi", () => {
request(`/api/session/${session.id}/prompt`, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
- body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "hello" } }),
+ body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "hello" }, resume: false }),
})
const first = yield* recordPrompt()
const retried = yield* recordPrompt()
@@ -641,7 +641,7 @@ describe("session HttpApi", () => {
const conflict = yield* request(`/api/session/${session.id}/prompt`, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
- body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "goodbye" } }),
+ body: JSON.stringify({ id: "msg_http_prompt", prompt: { text: "goodbye" }, resume: false }),
})
expect(conflict.status).toBe(409)
expect(yield* responseJson(conflict)).toEqual({
@@ -676,6 +676,27 @@ describe("session HttpApi", () => {
message: "Session wait is not available yet",
service: "session.wait",
})
+
+ const prompt = yield* request(`/api/session/${session.id}/prompt`, {
+ method: "POST",
+ headers: { ...headers, "content-type": "application/json" },
+ body: JSON.stringify({ id: "msg_execution_unavailable", prompt: { text: "hello" } }),
+ })
+ expect(prompt.status).toBe(503)
+ expect(yield* responseJson(prompt)).toEqual({
+ _tag: "ServiceUnavailableError",
+ message: "Session execution is not available on this endpoint",
+ service: "session.prompt",
+ })
+ const admitted = yield* Database.Service.use(({ db }) =>
+ db
+ .select()
+ .from(SessionInputTable)
+ .where(eq(SessionInputTable.id, SessionMessage.ID.make("msg_execution_unavailable")))
+ .get()
+ .pipe(Effect.orDie),
+ )
+ expect(admitted).toBeUndefined()
}),
{ git: true, config: { formatter: false, lsp: false } },
)
diff --git a/packages/server/src/groups/session.ts b/packages/server/src/groups/session.ts
index 8f0f4cff..b79d85fa 100644
--- a/packages/server/src/groups/session.ts
+++ b/packages/server/src/groups/session.ts
@@ -115,14 +115,15 @@ export const SessionGroup = HttpApiGroup.make("server.session")
resume: Schema.Boolean.pipe(Schema.optional),
}),
success: Schema.Struct({ data: SessionInput.Admitted }),
- error: [ConflictError, SessionNotFoundError],
+ error: [ConflictError, ServiceUnavailableError, SessionNotFoundError],
})
.middleware(SessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.prompt",
summary: "Send message",
- description: "Durably admit one session input and schedule agent-loop execution unless resume is false.",
+ description:
+ "Durably admit one session input when resume is false. This endpoint does not execute the production SessionPrompt engine.",
}),
),
)
diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts
index 50f7bc2f..73d1eb09 100644
--- a/packages/server/src/handlers.ts
+++ b/packages/server/src/handlers.ts
@@ -16,7 +16,6 @@ import { EventHandler } from "./handlers/event"
import { AgentHandler } from "./handlers/agent"
import { HealthHandler } from "./handlers/health"
import { QuestionHandler } from "./handlers/question"
-import * as SessionExecutionLocal from "@deepagent-code/core/session/execution/local"
export const handlers = Layer.mergeAll(
HealthHandler,
@@ -35,7 +34,6 @@ export const handlers = Layer.mergeAll(
Layer.provide(sessionLocationLayer),
Layer.provide(locationLayer),
Layer.provide(SessionV2.defaultLayer),
- Layer.provide(SessionExecutionLocal.defaultLayer),
Layer.provide(PermissionSaved.defaultLayer),
Layer.provide(LocationServiceMap.layer),
)
diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts
index a8ae9baf..4ce72e8a 100644
--- a/packages/server/src/handlers/session.ts
+++ b/packages/server/src/handlers/session.ts
@@ -64,6 +64,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.prompt",
Effect.fn(function* (ctx) {
+ if (ctx.payload.resume !== false) {
+ yield* session.get(ctx.params.sessionID).pipe(
+ Effect.catchTag("Session.NotFoundError", (error) =>
+ Effect.fail(
+ new SessionNotFoundError({
+ sessionID: error.sessionID,
+ message: `Session not found: ${error.sessionID}`,
+ }),
+ ),
+ ),
+ )
+ return yield* new ServiceUnavailableError({
+ message: "Session execution is not available on this endpoint",
+ service: "session.prompt",
+ })
+ }
return {
data: yield* session
.prompt({
@@ -71,7 +87,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
id: ctx.payload.id,
prompt: ctx.payload.prompt,
delivery: ctx.payload.delivery,
- resume: ctx.payload.resume,
+ resume: false,
})
.pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
From fa43d2bc24f7f530c5cce4cce162bfcb0d7aad91 Mon Sep 17 00:00:00 2001
From: deepagent-ai
Date: Sat, 25 Jul 2026 07:00:56 +0800
Subject: [PATCH 12/22] fix(core): keep knowledge review state live
---
.../review/dialog-review-contract.test.ts | 8 ++-
.../components/review/dialog-review.api.ts | 8 +++
packages/app/src/pages/layout.tsx | 5 +-
packages/core/src/agent-gateway.ts | 41 +++++++-----
packages/core/src/deepagent/document-store.ts | 6 ++
.../deepagent/environment-fact-adoption.ts | 18 ++---
.../core/src/deepagent/knowledge-source.ts | 28 ++++++--
.../deepagent/knowledge-source-cache.test.ts | 67 +++++++++++++++++++
.../src/import/writer/memory.ts | 21 +++---
.../instance/httpapi/groups/deepagent.ts | 20 +++++-
.../instance/httpapi/handlers/deepagent.ts | 40 ++++++-----
.../deepagent/knowledge-import-cache.test.ts | 65 ++++++++++++++++++
12 files changed, 261 insertions(+), 66 deletions(-)
create mode 100644 packages/core/test/deepagent/knowledge-source-cache.test.ts
create mode 100644 packages/deepagent-code/test/deepagent/knowledge-import-cache.test.ts
diff --git a/packages/app/src/components/review/dialog-review-contract.test.ts b/packages/app/src/components/review/dialog-review-contract.test.ts
index 6030a426..0a3b509b 100644
--- a/packages/app/src/components/review/dialog-review-contract.test.ts
+++ b/packages/app/src/components/review/dialog-review-contract.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
-import { listPending, setStatus, listEnvFacts, decideEnvFact, modifyEnvFact } from "./dialog-review.api"
+import { listPending, reviewSummary, setStatus, listEnvFacts, decideEnvFact, modifyEnvFact } from "./dialog-review.api"
// P1-C route contract: the V3.1 self-learning Review dialog talks to the raw-request escape-hatch
// routes (NOT the generated SDK). These assertions lock the exact method/url/body so a backend
@@ -43,6 +43,12 @@ describe("DeepAgent review dialog route contract", () => {
expect(await listPending(client(calls, {}))).toEqual([])
})
+ test("reviewSummary GETs the lightweight summary route", async () => {
+ const calls: Recorded[] = []
+ expect(await reviewSummary(client(calls, { pendingCount: 3 }))).toEqual({ pendingCount: 3 })
+ expect(calls).toEqual([{ method: "GET", url: "/deepagent/knowledge/review-summary" }])
+ })
+
test("approve POSTs /deepagent/knowledge/approve with { ids }", async () => {
const calls: Recorded[] = []
await setStatus(client(calls, { updated: ["a"] }), "approve", ["a", "b"])
diff --git a/packages/app/src/components/review/dialog-review.api.ts b/packages/app/src/components/review/dialog-review.api.ts
index 3f293b78..169ef00f 100644
--- a/packages/app/src/components/review/dialog-review.api.ts
+++ b/packages/app/src/components/review/dialog-review.api.ts
@@ -36,6 +36,14 @@ export const listPending = async (client: ReviewClient): Promise => {
+ const response = await client.client.request<{ pendingCount?: number }>({
+ method: "GET",
+ url: "/deepagent/knowledge/review-summary",
+ })
+ return { pendingCount: response.data?.pendingCount ?? 0 }
+}
+
export const setStatus = async (
client: ReviewClient,
action: "approve" | "reject-ids",
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 55705fdb..c2af49df 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -63,7 +63,7 @@ import { useTheme, type ColorScheme } from "@deepagent-code/ui/theme/context"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId, FixedDragDropSensors } from "@/utils/solid-dnd"
import { DebugBar } from "@/components/debug-bar"
-import { listPending } from "@/components/review/dialog-review.api"
+import { reviewSummary } from "@/components/review/dialog-review.api"
import { fetchCapabilities } from "@/components/deepagent/panel-goal.api"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useDirectoryPicker } from "@/components/directory-picker"
@@ -167,8 +167,7 @@ export default function Layout(props: ParentProps<{ onStartupRestoreSettled?: ()
if (!dir) return false
try {
const sdk = serverSDK.createDirSdkContext(dir)
- const items = await listPending(sdk.client as never)
- return items.some((item) => item.approval_status === "pending")
+ return (await reviewSummary(sdk.client as never)).pendingCount > 0
} catch {
return false
}
diff --git a/packages/core/src/agent-gateway.ts b/packages/core/src/agent-gateway.ts
index eab8efdb..12d3fc65 100644
--- a/packages/core/src/agent-gateway.ts
+++ b/packages/core/src/agent-gateway.ts
@@ -262,6 +262,8 @@ export const selfLearningPolicy = (): SelfLearningPolicy => current.selfLearning
// No disk writes — available only for diagnostics / budget tracking in-process.
type GeneralAuditEntry = { turnCount: number; totalInputTokens: number; totalOutputTokens: number }
const generalAuditState = new Map()
+const seededKnowledgeBases = new Set()
+let configuredStorageBaseDir: string | null = null
const recordGeneralAudit = (sessionID: string, inputTokens: number, outputTokens: number): void => {
const existing = generalAuditState.get(sessionID)
@@ -286,23 +288,27 @@ export const configure = (config: Config = {}) => {
// is still observed. This replaces the old path.dirname(runsDir) inference that made durable
// knowledge/state diverge from project-memory whenever runsDir pointed outside /runs.
const baseDir = config.baseDir ?? resolveDeepAgentCodeHome()
- DeepAgentSessionState.configure(path.join(baseDir, "state"))
- // I33-1: the structural plan lives in the single DocumentStore under /state/goal//graph
- // (co-located with the goal/run graph so the `plan` tool and the goal path write the SAME doc). Root
- // it from the SAME baseDir/state session-state uses, so the two paths converge (planStoreRoot ≡
- // goalStoreRoot). Must be set before any plan read/write.
- DeepAgentPlanStore.configureRoot(path.join(baseDir, "state"))
- // docs/34 §7.2/§8: durable knowledge stores root under baseDir (public/knowledge + project//
- // knowledge). The retriever's knowledge-source reads from here. Same injected baseDir, no self-resolve.
- DeepAgentKnowledgeSource.configure(baseDir)
- // docs/34 §9 DAP-11: seed core in-code knowledge (CORE_STRATEGIES/METHODOLOGY_REGISTRY/gpu pack)
- // into the user-global DocumentStore on every configure() call. seedCoreKnowledge is idempotent
- // (skips docs that already exist), so re-calling on restart is safe. After seeding, the retriever
- // reads these from DocumentStore and the in-code constants are no longer in the retrieval path.
- try {
- DeepAgentKnowledgeSeed.seedCoreKnowledgeAt(baseDir)
- } catch {
- /* non-fatal: stale seed, retry next call */
+ const resolvedBaseDir = path.resolve(baseDir)
+ if (configuredStorageBaseDir !== resolvedBaseDir) {
+ DeepAgentSessionState.configure(path.join(resolvedBaseDir, "state"))
+ // I33-1: the structural plan lives under /state/goal//graph. Root it from the SAME
+ // state directory before any plan read/write. SessionState.configure also applies this root; the
+ // explicit call documents the gateway ownership boundary.
+ DeepAgentPlanStore.configureRoot(path.join(resolvedBaseDir, "state"))
+ configuredStorageBaseDir = resolvedBaseDir
+ }
+ // configure() is identity-preserving for the same root, so request-time policy updates do not drop
+ // the live knowledge stores. It also restores the adapter if an explicit test reset it.
+ DeepAgentKnowledgeSource.configure(resolvedBaseDir)
+ // Seed each storage root once per process. Reconfiguration changes runtime policy frequently, but
+ // the built-in corpus only changes across process/app versions and must not rebuild its disk index.
+ if (!seededKnowledgeBases.has(resolvedBaseDir)) {
+ try {
+ DeepAgentKnowledgeSeed.seedCoreKnowledge(DeepAgentKnowledgeSource.userGlobalStoreFor())
+ seededKnowledgeBases.add(resolvedBaseDir)
+ } catch {
+ /* non-fatal: stale seed, retry next call */
+ }
}
// docs/34 §3: domain pack registry. Built-in packs (packages/domain-packs, bundled with the app)
// are ALWAYS discovered automatically. A user/org pack dir can be layered on top via config.packDir
@@ -835,7 +841,6 @@ const markStaleEnvironmentFactsFromRun = (
if (stale.length === 0) return
const userGlobal = DeepAgentKnowledgeSource.userGlobalStoreFor()
for (const id of stale) userGlobal.markEnvironmentFactStale(id)
- DeepAgentKnowledgeSource.invalidateCache()
}
// Concatenate the output of every FAILED validation this run recorded — that's where a tool's
diff --git a/packages/core/src/deepagent/document-store.ts b/packages/core/src/deepagent/document-store.ts
index e66eee41..1bceab24 100644
--- a/packages/core/src/deepagent/document-store.ts
+++ b/packages/core/src/deepagent/document-store.ts
@@ -674,6 +674,12 @@ export class DocumentStore {
}
private findLogical(input: CreateDocInput): Doc | null {
const domain = input.domain ?? null
+ if (input.idSlug) {
+ const slug = slugify(input.idSlug)
+ const id = domain ? `doc:${input.type}:${domain}:${slug}` : `doc:${input.type}:${slug}`
+ const direct = this.get(id)
+ if (direct?.domain === domain && direct.description === input.description) return direct
+ }
for (const ref of this.list({ type: input.type, scope: input.scope })) {
const doc = this.get(ref.id)
if (!doc) continue
diff --git a/packages/core/src/deepagent/environment-fact-adoption.ts b/packages/core/src/deepagent/environment-fact-adoption.ts
index f692a1e7..00cb146a 100644
--- a/packages/core/src/deepagent/environment-fact-adoption.ts
+++ b/packages/core/src/deepagent/environment-fact-adoption.ts
@@ -3,11 +3,7 @@ import path from "node:path"
import type { ProjectPaths } from "./workspace"
import type { AdoptionRecord } from "./environment-fact"
import { useGateAction, type EnvironmentFactBody } from "./environment-fact"
-import {
- openUserGlobalStore,
- openProjectStore,
- type DurableKnowledgeStore,
-} from "./durable-knowledge-store"
+import { openUserGlobalStore, openProjectStore, type DurableKnowledgeStore } from "./durable-knowledge-store"
import type { DocRef } from "./document-store"
// V3.8.1 §G.5 use-gate persistence. A project's stance toward each user-global provisional
@@ -69,9 +65,10 @@ export class EnvironmentFactAdoption {
private readonly baseDir: string,
private readonly paths: ProjectPaths,
private readonly workspacePath: string,
+ stores?: { readonly userGlobal: DurableKnowledgeStore; readonly project: DurableKnowledgeStore },
) {
- this.userGlobal = openUserGlobalStore(baseDir)
- this.project = openProjectStore(baseDir, workspacePath)
+ this.userGlobal = stores?.userGlobal ?? openUserGlobalStore(baseDir)
+ this.project = stores?.project ?? openProjectStore(baseDir, workspacePath)
}
private records(): AdoptionRecord[] {
@@ -172,7 +169,12 @@ export class EnvironmentFactAdoption {
domain: input.domain ?? null,
provenance: { source: "human" },
})
- this.writeRecord({ fact_id: updated.id, stance: "adopted", decided_at: input.now, adopted_version: updated.version })
+ this.writeRecord({
+ fact_id: updated.id,
+ stance: "adopted",
+ decided_at: input.now,
+ adopted_version: updated.version,
+ })
return { updatedId: updated.id }
}
diff --git a/packages/core/src/deepagent/knowledge-source.ts b/packages/core/src/deepagent/knowledge-source.ts
index 2bf841a2..49ade60d 100644
--- a/packages/core/src/deepagent/knowledge-source.ts
+++ b/packages/core/src/deepagent/knowledge-source.ts
@@ -9,15 +9,16 @@ import {
import type { DocType, DocumentStore } from "./document-store"
import { DeepAgentCodeHome } from "./workspace"
import { EnvironmentFactAdoption } from "./environment-fact-adoption"
+import path from "node:path"
// V3.2.1 decision B (docs/34 §8): the read-side adapter between the knowledge retriever and the
// durable DocumentStore. Durable knowledge lives in TWO roots under the single injected base
// (Global.Path.agent.data): user-global (public/knowledge, visible everywhere) and per-project
// (project//knowledge, project-shared isolation). A retrieval for a workspace UNIONS both.
//
-// Mirrors the old memory-store module pattern: a single configured base + a clearable cache, so the
-// retriever stays a pure-ish function (retrieve(input)) and approve/reject is reflected after
-// invalidateCache(). This module is the ONLY durable read path the retriever uses.
+// The configured stores are process-long-lived. All in-process writers use these same handles, so
+// changes are immediately visible without rebuilding the disk index. invalidateCache() is reserved
+// for explicit cold-reload/testing paths. This module is the ONLY durable read path the retriever uses.
let baseDir: string | null = null
let userGlobalCache: DurableKnowledgeStore | null = null
@@ -31,14 +32,19 @@ let sharedDocumentStore: DocumentStore | null = null
// configure, from the injected baseDir — never a self-resolved home).
// H32-1: optional sharedStore accepted; passed through to openUserGlobalStore/openProjectStore.
export const configure = (dir: string, sharedStore?: DocumentStore): void => {
- baseDir = dir
- sharedDocumentStore = sharedStore ?? null
+ const nextBaseDir = path.resolve(dir)
+ const nextSharedDocumentStore = sharedStore ?? null
+ if (baseDir === nextBaseDir && sharedDocumentStore === nextSharedDocumentStore) return
+ baseDir = nextBaseDir
+ sharedDocumentStore = nextSharedDocumentStore
userGlobalCache = null
projectCache.clear()
}
export const isConfigured = (): boolean => baseDir !== null
+export const isConfiguredFor = (dir: string): boolean => baseDir === path.resolve(dir)
+
// Reset to the unconfigured state (baseDir=null + caches cleared). `configure` is a process-global
// setter with no other way back to null; tests that assert the UNCONFIGURED path (isConfigured()===false
// → callers fall back to empty results) need this to guarantee their precondition regardless of a prior
@@ -51,7 +57,8 @@ export const reset = (): void => {
projectCache.clear()
}
-// Clear cached stores so a subsequent query re-reads from disk (after approve/reject/seed).
+// Clear cached stores so a subsequent query re-reads from disk. Normal in-process writes must use
+// the cached handles instead; this cold path is only for explicit external-change recovery/tests.
export const invalidateCache = (): void => {
userGlobalCache = null
projectCache.clear()
@@ -152,7 +159,10 @@ export const environmentFactAdoptionFor = (workspacePath: string): EnvironmentFa
const base = ensureBase()
const home = new DeepAgentCodeHome(base)
const paths = home.ensureProject(projectIdForWorkspace(workspacePath), workspacePath)
- return new EnvironmentFactAdoption(base, paths, workspacePath)
+ return new EnvironmentFactAdoption(base, paths, workspacePath, {
+ userGlobal: userGlobalStore(),
+ project: projectStore(workspacePath),
+ })
}
// Open the project store for a workspace path. Throws if not configured.
@@ -184,6 +194,10 @@ export const listAllForWorkspace = (workspacePath: string): readonly ReviewItem[
}
return out
}
+
+export const reviewSummaryForWorkspace = (workspacePath: string): { readonly pendingCount: number } => ({
+ pendingCount: listByStatusForWorkspace(workspacePath, "candidate").filter((item) => item.type !== "skill").length,
+})
export type ReviewItem = {
readonly id: string
readonly type: import("./document-store").DocType
diff --git a/packages/core/test/deepagent/knowledge-source-cache.test.ts b/packages/core/test/deepagent/knowledge-source-cache.test.ts
new file mode 100644
index 00000000..1a11f779
--- /dev/null
+++ b/packages/core/test/deepagent/knowledge-source-cache.test.ts
@@ -0,0 +1,67 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { mkdtempSync, rmSync } from "node:fs"
+import { tmpdir } from "node:os"
+import path from "node:path"
+import {
+ configure,
+ isConfiguredFor,
+ projectStoreFor,
+ reset,
+ reviewSummaryForWorkspace,
+ userGlobalStoreFor,
+} from "../../src/deepagent/knowledge-source"
+
+const roots: string[] = []
+
+const root = () => {
+ const value = mkdtempSync(path.join(tmpdir(), "deepagent-knowledge-cache-"))
+ roots.push(value)
+ return value
+}
+
+afterEach(() => {
+ reset()
+ for (const value of roots.splice(0)) rmSync(value, { recursive: true, force: true })
+})
+
+describe("knowledge source cache", () => {
+ test("reuses stores when the configured storage root is unchanged", () => {
+ const base = root()
+ configure(base)
+ const first = userGlobalStoreFor()
+
+ configure(path.join(base, "."))
+
+ expect(isConfiguredFor(path.join(base, "."))).toBe(true)
+ expect(userGlobalStoreFor()).toBe(first)
+ })
+
+ test("replaces stores when the configured storage root changes", () => {
+ configure(root())
+ const first = userGlobalStoreFor()
+
+ configure(root())
+
+ expect(userGlobalStoreFor()).not.toBe(first)
+ })
+
+ test("summarizes pending review items from the live cache", () => {
+ const base = root()
+ const workspace = path.join(base, "workspace")
+ configure(base)
+ projectStoreFor(workspace).stageCandidate({
+ type: "memory",
+ description: "review this learned behavior",
+ body: "review this learned behavior",
+ domain: "code",
+ scope: "project-shared",
+ projectId: "project-test",
+ sensitivity: "public",
+ risk: "low",
+ confidence: { evidence_strength: "medium", support_count: 1 },
+ provenance: { source: "runner", run_ref: "run-1", evidence_refs: [] },
+ })
+
+ expect(reviewSummaryForWorkspace(workspace)).toEqual({ pendingCount: 1 })
+ })
+})
diff --git a/packages/deepagent-code/src/import/writer/memory.ts b/packages/deepagent-code/src/import/writer/memory.ts
index b1760751..6dc14ffb 100644
--- a/packages/deepagent-code/src/import/writer/memory.ts
+++ b/packages/deepagent-code/src/import/writer/memory.ts
@@ -4,8 +4,9 @@ import {
type DurableKnowledgeStore,
openProjectStore,
openUserGlobalStore,
+ projectIdForWorkspace,
} from "@deepagent-code/core/deepagent/durable-knowledge-store"
-import * as KnowledgeSource from "@deepagent-code/core/deepagent/knowledge-source"
+import { isConfiguredFor, projectStoreFor, userGlobalStoreFor } from "@deepagent-code/core/deepagent/knowledge-source"
import { classifyReview, DEFAULT_CONFIG } from "@deepagent-code/core/deepagent/auto-reviewer"
import { looksSensitive } from "@deepagent-code/core/deepagent/memory-governance"
import type { Doc } from "@deepagent-code/core/deepagent/document-store"
@@ -35,7 +36,13 @@ export function stageAndReviewMemories(memories: MemoryItem[], baseDir: string):
let staged = 0
for (const item of memories) {
- const store = item.cwd ? openProjectStore(baseDir, item.cwd) : openUserGlobalStore(baseDir)
+ const store = isConfiguredFor(baseDir)
+ ? item.cwd
+ ? projectStoreFor(item.cwd)
+ : userGlobalStoreFor()
+ : item.cwd
+ ? openProjectStore(baseDir, item.cwd)
+ : openUserGlobalStore(baseDir)
// Dedup store instances so the review pass walks each store once even when
// many memories share the same root (cwd or user-global).
const storeKey = item.cwd ?? "__global__"
@@ -49,6 +56,7 @@ export function stageAndReviewMemories(memories: MemoryItem[], baseDir: string):
body: item.body,
domain: null,
scope: item.cwd ? "project-shared" : "user-global",
+ ...(item.cwd ? { projectId: projectIdForWorkspace(item.cwd) } : {}),
sensitivity: "public",
risk: "low",
confidence: { evidence_strength: "weak", support_count: 1 },
@@ -60,7 +68,6 @@ export function stageAndReviewMemories(memories: MemoryItem[], baseDir: string):
}
const { approved, pending } = autoReviewMemories(stores)
- invalidateKnowledgeCache()
return { staged, writtenToInstructions: false, approved, pending }
}
@@ -120,14 +127,6 @@ function shouldAutoApprove(doc: Doc): boolean {
return true
}
-function invalidateKnowledgeCache(): void {
- try {
- KnowledgeSource.invalidateCache()
- } catch {
- /* knowledge-source not configured in this process (e.g. CLI) — safe to skip */
- }
-}
-
/**
* Fallback / always-on path: append imported memories to an AGENTS.md the
* instruction-context loader auto-reads, so they are immediately visible as
diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/groups/deepagent.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/groups/deepagent.ts
index 905666fe..b074359e 100644
--- a/packages/deepagent-code/src/server/routes/instance/httpapi/groups/deepagent.ts
+++ b/packages/deepagent-code/src/server/routes/instance/httpapi/groups/deepagent.ts
@@ -136,6 +136,8 @@ export const DeepAgentKnowledgeItem = Schema.Struct({
export const DeepAgentKnowledgeList = Schema.Struct({ items: Schema.Array(DeepAgentKnowledgeItem) })
+export const DeepAgentKnowledgeReviewSummary = Schema.Struct({ pendingCount: Schema.Number })
+
export const DeepAgentKnowledgeStatusInput = Schema.Struct({ ids: Schema.Array(Schema.String) })
export const DeepAgentKnowledgeStatusResult = Schema.Struct({ updated: Schema.Array(Schema.String) })
@@ -507,6 +509,19 @@ export const DeepAgentApi = HttpApi.make("deepagent").add(
}),
),
)
+ .add(
+ HttpApiEndpoint.get("knowledgeReviewSummary", `${root}/knowledge/review-summary`, {
+ query: WorkspaceRoutingQuery,
+ success: described(DeepAgentKnowledgeReviewSummary, "Pending durable knowledge count for the Review badge"),
+ error: DeepAgentPromotionError,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "deepagent.knowledge.reviewSummary",
+ summary: "Get the DeepAgent knowledge review summary",
+ description: "Return the pending review count without projecting the complete knowledge review list.",
+ }),
+ ),
+ )
.add(
HttpApiEndpoint.post("knowledgeApprove", `${root}/knowledge/approve`, {
query: WorkspaceRoutingQuery,
@@ -656,7 +671,10 @@ export const DeepAgentApi = HttpApi.make("deepagent").add(
.add(
HttpApiEndpoint.get("panelStatus", `${root}/panel/status`, {
query: Schema.Struct({ ...WorkspaceRoutingQueryFields, sessionID: Schema.String }),
- success: described(DeepAgentPanelStatusResult, "Effective panel armed state (explicit toggle or global default)"),
+ success: described(
+ DeepAgentPanelStatusResult,
+ "Effective panel armed state (explicit toggle or global default)",
+ ),
error: DeepAgentPromotionError,
}).annotateMerge(
OpenApi.annotations({
diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/deepagent.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/deepagent.ts
index 210d9e9b..caebfe05 100644
--- a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/deepagent.ts
+++ b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/deepagent.ts
@@ -60,9 +60,11 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen
model: { providerID: model.providerID, modelID: model.modelID },
})
return (turnInput) =>
- runTurn({ agentType: turnInput.agentType, prompt: turnInput.prompt, outputSchema: turnInput.outputSchema }).pipe(
- Effect.map((r) => ({ structured: r.structured })),
- )
+ runTurn({
+ agentType: turnInput.agentType,
+ prompt: turnInput.prompt,
+ outputSchema: turnInput.outputSchema,
+ }).pipe(Effect.map((r) => ({ structured: r.structured })))
})
const resolveReviewRunsDir = Effect.fn("DeepAgentHttpApi.resolveReviewRunsDir")(function* () {
@@ -147,7 +149,6 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen
record,
AgentGateway.DeepAgentKnowledgeSource.userGlobalStoreFor(),
)
- AgentGateway.DeepAgentKnowledgeRetriever.invalidateCache()
return record
},
catch: (error) =>
@@ -172,7 +173,6 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen
ctx.payload.candidate.candidate_id,
"rejected",
)
- AgentGateway.DeepAgentKnowledgeRetriever.invalidateCache()
return { candidateId: ctx.payload.candidate.candidate_id, fingerprint, reason: ctx.payload.reason }
},
catch: (error) =>
@@ -213,13 +213,21 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen
})
})
+ const knowledgeReviewSummary = Effect.fn("DeepAgentHttpApi.knowledgeReviewSummary")(function* () {
+ const dir = yield* workspaceDir()
+ return yield* Effect.try({
+ try: () => AgentGateway.DeepAgentKnowledgeSource.reviewSummaryForWorkspace(dir),
+ catch: (error) =>
+ new DeepAgentPromotionError({ message: error instanceof Error ? error.message : String(error) }),
+ })
+ })
+
const knowledgeApprove = Effect.fn("DeepAgentHttpApi.knowledgeApprove")(function* (ctx) {
const dir = yield* workspaceDir()
return yield* Effect.try({
try: () => {
for (const id of ctx.payload.ids)
AgentGateway.DeepAgentKnowledgeSource.setApprovalForWorkspace(dir, id, "approved")
- AgentGateway.DeepAgentKnowledgeRetriever.invalidateCache()
return { updated: ctx.payload.ids }
},
catch: (error) =>
@@ -233,7 +241,6 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen
try: () => {
for (const id of ctx.payload.ids)
AgentGateway.DeepAgentKnowledgeSource.setApprovalForWorkspace(dir, id, "rejected")
- AgentGateway.DeepAgentKnowledgeRetriever.invalidateCache()
return { updated: ctx.payload.ids }
},
catch: (error) =>
@@ -273,7 +280,6 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen
if (AgentGateway.DeepAgentKnowledgeSource.setApprovalForWorkspace(dir, ref, "rejected")) demoted.push(ref)
else notInStore.push(ref)
}
- AgentGateway.DeepAgentKnowledgeRetriever.invalidateCache()
}
return {
ship: decision.ship,
@@ -415,8 +421,8 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen
})
// V3.8.1 §G environment-fact use-gate handlers. The adoption service roots at the same gateway
- // baseDir the retriever reads (workspaceDir() calls configureGateway first), keyed by the active
- // workspace path — so a project's adopt/reject decisions are isolated per project (§G.8).
+ // baseDir the retriever reads, keyed by the active workspace path — so a project's adopt/reject
+ // decisions are isolated per project (§G.8).
const now = () => new Date().toISOString()
const envFacts = Effect.fn("DeepAgentHttpApi.envFacts")(function* () {
@@ -435,7 +441,6 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen
const adoption = AgentGateway.DeepAgentKnowledgeSource.environmentFactAdoptionFor(dir)
if (ctx.payload.decision === "adopt") adoption.adopt(ctx.payload.factId, now())
else adoption.reject(ctx.payload.factId, now())
- AgentGateway.DeepAgentKnowledgeRetriever.invalidateCache()
return { ok: true, factId: ctx.payload.factId }
},
catch: (error) =>
@@ -456,7 +461,6 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen
mode: ctx.payload.mode,
now: now(),
})
- AgentGateway.DeepAgentKnowledgeRetriever.invalidateCache()
return { ok: true, factId: updatedId }
},
catch: (error) =>
@@ -504,7 +508,8 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen
const verdict = yield* consultPanel(
{
question:
- ctx.payload.question ?? "Review the current changes in this conversation for correctness, security, and design.",
+ ctx.payload.question ??
+ "Review the current changes in this conversation for correctness, security, and design.",
codeRefs: ctx.payload.codeRefs ? [...ctx.payload.codeRefs] : [],
parentSessionID: sessionID,
...(ctx.payload.lenses ? { lenses: [...ctx.payload.lenses] } : {}),
@@ -519,9 +524,7 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen
// The global Expert Panel default (§C): the effective armed state falls back to this when a session
// has never explicitly toggled. Read from the first-party SettingsStore (expertPanelDefault).
const expertPanelDefault = () =>
- Effect.promise(() => SettingsStore.read()).pipe(
- Effect.map((s) => s.deepagent?.expertPanelDefault ?? false),
- )
+ Effect.promise(() => SettingsStore.read()).pipe(Effect.map((s) => s.deepagent?.expertPanelDefault ?? false))
const panelArm = Effect.fn("DeepAgentHttpApi.panelArm")(function* (ctx) {
const { sessionID, armed, rounds } = ctx.payload
@@ -701,7 +704,9 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen
...(ctx.query.scope ? { scope: ctx.query.scope } : {}),
})
index.close()
- return { hits: hits.map((h) => ({ docId: h.docId, type: h.type, scope: h.scope, title: h.title, score: h.score })) }
+ return {
+ hits: hits.map((h) => ({ docId: h.docId, type: h.type, scope: h.scope, title: h.title, score: h.score })),
+ }
})
const wikiEdit = Effect.fn("DeepAgentHttpApi.wikiEdit")(function* (ctx) {
@@ -748,6 +753,7 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen
.handle("promote", promote)
.handle("reject", reject)
.handle("knowledgePending", knowledgePending)
+ .handle("knowledgeReviewSummary", knowledgeReviewSummary)
.handle("knowledgeApprove", knowledgeApprove)
.handle("knowledgeRejectIds", knowledgeRejectIds)
.handle("knowledgeShipGate", knowledgeShipGate)
diff --git a/packages/deepagent-code/test/deepagent/knowledge-import-cache.test.ts b/packages/deepagent-code/test/deepagent/knowledge-import-cache.test.ts
new file mode 100644
index 00000000..0578dd73
--- /dev/null
+++ b/packages/deepagent-code/test/deepagent/knowledge-import-cache.test.ts
@@ -0,0 +1,65 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { mkdtempSync, rmSync } from "node:fs"
+import { tmpdir } from "node:os"
+import path from "node:path"
+import {
+ configure,
+ reset,
+ reviewSummaryForWorkspace,
+ userGlobalStoreFor,
+} from "@deepagent-code/core/deepagent/knowledge-source"
+import { stageAndReviewMemories } from "../../src/import/writer/memory"
+
+const roots: string[] = []
+
+afterEach(() => {
+ reset()
+ for (const value of roots.splice(0)) rmSync(value, { recursive: true, force: true })
+})
+
+describe("knowledge import cache", () => {
+ test("writes through the configured store without invalidating it", () => {
+ const base = mkdtempSync(path.join(tmpdir(), "deepagent-knowledge-import-cache-"))
+ roots.push(base)
+ configure(base)
+ const store = userGlobalStoreFor()
+
+ expect(
+ stageAndReviewMemories(
+ [
+ {
+ source: "codex",
+ slug: "safe-memory",
+ title: "Safe memory",
+ body: "Use the repository typecheck command before submitting a change.",
+ },
+ ],
+ base,
+ ),
+ ).toEqual({ staged: 1, writtenToInstructions: false, approved: 1, pending: 0 })
+ expect(userGlobalStoreFor()).toBe(store)
+ })
+
+ test("makes imported project candidates visible in the live review summary", () => {
+ const base = mkdtempSync(path.join(tmpdir(), "deepagent-knowledge-import-cache-"))
+ const workspace = path.join(base, "workspace")
+ roots.push(base)
+ configure(base)
+
+ const result = stageAndReviewMemories(
+ [
+ {
+ source: "codex",
+ slug: "secret-memory",
+ title: "Secret memory",
+ body: "API_TOKEN=example-secret-value-that-needs-human-review",
+ cwd: workspace,
+ },
+ ],
+ base,
+ )
+
+ expect(result.pending).toBe(1)
+ expect(reviewSummaryForWorkspace(workspace)).toEqual({ pendingCount: 1 })
+ })
+})
From 551b6cb4c7c156ae26c6dd6aff1ede55601db239 Mon Sep 17 00:00:00 2001
From: deepagent-ai
Date: Sat, 25 Jul 2026 07:01:32 +0800
Subject: [PATCH 13/22] feat(app): group wiki content by graph
---
.../src/components/wiki/dialog-wiki.test.ts | 22 +++
.../app/src/components/wiki/dialog-wiki.tsx | 139 ++++++++++++------
2 files changed, 120 insertions(+), 41 deletions(-)
create mode 100644 packages/app/src/components/wiki/dialog-wiki.test.ts
diff --git a/packages/app/src/components/wiki/dialog-wiki.test.ts b/packages/app/src/components/wiki/dialog-wiki.test.ts
new file mode 100644
index 00000000..7ed49225
--- /dev/null
+++ b/packages/app/src/components/wiki/dialog-wiki.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, test } from "bun:test"
+import { resolveWikiExpandedGroup, type WikiGroup } from "./dialog-wiki"
+
+const groups = (counts: Partial>) =>
+ (["knowledge", "memory", "code", "document"] as const).map((group) => ({
+ group,
+ items: Array.from({ length: counts[group] ?? 0 }, (_, index) => ({ id: index })),
+ }))
+
+describe("wiki graph expansion", () => {
+ test("search reveals the first graph containing a result", () => {
+ expect(resolveWikiExpandedGroup(groups({ code: 2, document: 1 }), "knowledge", true)).toBe("code")
+ })
+
+ test("empty search results close the prior graph instead of showing a misleading empty group", () => {
+ expect(resolveWikiExpandedGroup(groups({}), "knowledge", true)).toBeUndefined()
+ })
+
+ test("manual expansion is preserved outside search", () => {
+ expect(resolveWikiExpandedGroup(groups({ document: 1 }), "memory", false)).toBe("memory")
+ })
+})
diff --git a/packages/app/src/components/wiki/dialog-wiki.tsx b/packages/app/src/components/wiki/dialog-wiki.tsx
index 3d9170b8..f2ec1e87 100644
--- a/packages/app/src/components/wiki/dialog-wiki.tsx
+++ b/packages/app/src/components/wiki/dialog-wiki.tsx
@@ -1,6 +1,7 @@
-import { Component, createMemo, createResource, createSignal, For, Show } from "solid-js"
+import { Component, createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"
import { Dialog } from "@deepagent-code/ui/v2/dialog-v2"
import { Button } from "@deepagent-code/ui/button"
+import { Collapsible } from "@deepagent-code/ui/collapsible"
import { Icon } from "@deepagent-code/ui/icon"
import { Markdown } from "@deepagent-code/ui/markdown"
import { useLanguage } from "@/context/language"
@@ -28,22 +29,30 @@ export {
} from "./wiki.api"
// §B.2 governance grouping: two governable (Knowledge/Memory), two monitor-only (Document/Code).
-const TYPE_GROUP: Record = {
+export type WikiGroup = "knowledge" | "memory" | "code" | "document"
+
+const TYPE_GROUP: Record = {
knowledge: "knowledge",
strategy: "knowledge",
methodology: "knowledge",
memory: "memory",
code_symbol: "code",
}
-const groupOf = (type: string): "knowledge" | "memory" | "code" | "document" =>
- (TYPE_GROUP[type] as "knowledge" | "memory" | "code") ?? "document"
+const groupOf = (type: string): WikiGroup => TYPE_GROUP[type] ?? "document"
+
+const GROUP_ORDER: readonly WikiGroup[] = ["knowledge", "memory", "code", "document"]
+const GROUP_ICON = {
+ knowledge: "knowledge-check",
+ memory: "brain",
+ code: "code-lines",
+ document: "open-file",
+} as const
-const GROUP_ORDER: ReadonlyArray<"knowledge" | "memory" | "document" | "code"> = [
- "knowledge",
- "memory",
- "document",
- "code",
-]
+export const resolveWikiExpandedGroup = (
+ groups: readonly { group: WikiGroup; items: readonly unknown[] }[],
+ expanded: WikiGroup | undefined,
+ searching: boolean,
+) => (searching ? groups.find((group) => group.items.length > 0)?.group : expanded)
export const DialogWiki: Component<{ client: WikiClient }> = (props) => {
const language = useLanguage()
@@ -83,7 +92,7 @@ export const DialogWiki: Component<{ client: WikiClient }> = (props) => {
list.push(p)
byGroup.set(g, list)
}
- return GROUP_ORDER.map((g) => ({ group: g, items: byGroup.get(g) ?? [] })).filter((x) => x.items.length > 0)
+ return GROUP_ORDER.map((group) => ({ group, items: byGroup.get(group) ?? [] }))
})
// The rendered detail page for the current selection.
@@ -157,10 +166,11 @@ export const DialogWiki: Component<{ client: WikiClient }> = (props) => {
language.t(`wiki.type.${groupOf(t)}`)}
+ typeLabel={(group) => language.t(`wiki.type.${group}`)}
/>
= (props) => {
}
const WikiList: Component<{
- groups: { group: string; items: WikiPageSummary[] }[]
+ groups: { group: WikiGroup; items: WikiPageSummary[] }[]
loading: boolean
+ searchKey: string
empty: string
selectedId: string | undefined
onSelect: (p: WikiPageSummary) => void
- typeLabel: (type: string) => string
+ typeLabel: (group: WikiGroup) => string
}> = (props) => {
const language = useLanguage()
+ const [expanded, setExpanded] = createSignal()
+ createEffect(() => {
+ const searchKey = props.searchKey
+ const groups = props.groups
+ if (!searchKey) return
+ setExpanded(resolveWikiExpandedGroup(groups, undefined, true))
+ })
return (
-
+ void }> = (props) =>
- {/* §3.4.3: [Open] button navigates to the subagent's full session. */}
- {
- e.stopPropagation()
- // The router nests every session route under a required `:dir` segment
- // (`/:dir/session/:id`). `params.dir` is the parent's dir and the
- // subagent lives in the same scope — matching every other session
- // navigation in the app (message-timeline, session-composer, etc.).
- navigate(`/${params.dir}/session/${child.id}`)
- }}
- >
- {language.t("session.subagents.open")}
-
-
+
+
+
+
+
+ {/* §3.4.3: sibling interactive control — never nest a button inside the row button. */}
+ navigate(`/${params.dir}/session/${child.id}`)}
+ >
+ {language.t("session.subagents.open")}
)
diff --git a/packages/app/src/pages/session/subagent-state.test.ts b/packages/app/src/pages/session/subagent-state.test.ts
index e5ff23d2..2e78f093 100644
--- a/packages/app/src/pages/session/subagent-state.test.ts
+++ b/packages/app/src/pages/session/subagent-state.test.ts
@@ -7,4 +7,12 @@ describe("isSubagentInterrupted", () => {
expect(isSubagentInterrupted({ deepagent: { subagent: { interrupted: true } } })).toBe(true)
expect(isSubagentInterrupted({ deepagent: { subagent: { state: "finished" } } })).toBe(false)
})
+
+ test("reads metadata from a real session-shaped object", () => {
+ expect(
+ isSubagentInterrupted({
+ metadata: { deepagent: { subagent: { state: "interrupted", reason: "human" } } },
+ }),
+ ).toBe(true)
+ })
})
diff --git a/packages/app/src/pages/session/subagent-state.ts b/packages/app/src/pages/session/subagent-state.ts
index 188ceafd..06079607 100644
--- a/packages/app/src/pages/session/subagent-state.ts
+++ b/packages/app/src/pages/session/subagent-state.ts
@@ -1,10 +1,29 @@
type SubagentMetadata = {
+ finished?: boolean
state?: string
+ reason?: string
interrupted?: boolean
}
-/** Supports durable state markers and legacy boolean interruption markers. */
-export const isSubagentInterrupted = (metadata?: Record) => {
- const subagent = (metadata?.["deepagent"] as { subagent?: SubagentMetadata } | undefined)?.subagent
+const isRecord = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && !Array.isArray(value)
+
+export const subagentMetadata = (value?: unknown) => {
+ if (!isRecord(value)) return undefined
+ const metadata = isRecord(value.metadata) ? value.metadata : value
+ const deepagent = isRecord(metadata.deepagent) ? metadata.deepagent : undefined
+ const subagent = isRecord(deepagent?.subagent) ? deepagent.subagent : undefined
+ if (!subagent) return undefined
+ return {
+ ...(typeof subagent.finished === "boolean" ? { finished: subagent.finished } : {}),
+ ...(typeof subagent.state === "string" ? { state: subagent.state } : {}),
+ ...(typeof subagent.reason === "string" ? { reason: subagent.reason } : {}),
+ ...(typeof subagent.interrupted === "boolean" ? { interrupted: subagent.interrupted } : {}),
+ } satisfies SubagentMetadata
+}
+
+/** Supports durable state markers, real Session records, and legacy boolean interruption markers. */
+export const isSubagentInterrupted = (value?: unknown) => {
+ const subagent = subagentMetadata(value)
return subagent?.state === "interrupted" || subagent?.interrupted === true
}
diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts
index b8ff85ff..74ba0956 100644
--- a/packages/core/src/database/migration.gen.ts
+++ b/packages/core/src/database/migration.gen.ts
@@ -54,5 +54,6 @@ export const migrations = (
import("./migration/20260712050000_session_steer_queue"),
import("./migration/20260719000000_deepagent_consumer_group"),
import("./migration/20260722000000_session_steer_correlation"),
+ import("./migration/20260724134000_task_run_delivery"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
diff --git a/packages/core/src/database/migration/20260724134000_task_run_delivery.ts b/packages/core/src/database/migration/20260724134000_task_run_delivery.ts
new file mode 100644
index 00000000..95cd5318
--- /dev/null
+++ b/packages/core/src/database/migration/20260724134000_task_run_delivery.ts
@@ -0,0 +1,92 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+export default {
+ id: "20260724134000_task_run_delivery",
+ up(tx) {
+ return Effect.gen(function* () {
+ yield* tx.run(`
+ CREATE TABLE IF NOT EXISTS task_run (
+ run_id TEXT PRIMARY KEY,
+ root_run_id TEXT,
+ request_hash TEXT NOT NULL,
+ parent_session_id TEXT NOT NULL REFERENCES session(id) ON DELETE CASCADE,
+ parent_message_id TEXT NOT NULL,
+ tool_call_id TEXT NOT NULL,
+ child_session_id TEXT NOT NULL,
+ generation INTEGER NOT NULL,
+ delivery_mode TEXT NOT NULL CHECK (delivery_mode IN ('foreground', 'background')),
+ phase TEXT NOT NULL CHECK (phase IN ('admission', 'research', 'finalize', 'settled')),
+ state TEXT NOT NULL CHECK (state IN ('admitted', 'provisioning', 'researching', 'finalizing', 'completed', 'error', 'cancelled', 'interrupted')),
+ reason TEXT,
+ attempts INTEGER NOT NULL DEFAULT 0,
+ execution_owner TEXT,
+ lease_expires_at INTEGER,
+ raw_result_message_id TEXT,
+ structured_result_message_id TEXT,
+ output TEXT,
+ error TEXT,
+ time_created INTEGER NOT NULL,
+ time_updated INTEGER NOT NULL,
+ time_settled INTEGER
+ )
+ `)
+ yield* tx.run(`
+ CREATE UNIQUE INDEX IF NOT EXISTS task_run_child_generation_idx
+ ON task_run (child_session_id, generation)
+ `)
+ yield* tx.run(`
+ CREATE UNIQUE INDEX IF NOT EXISTS task_run_child_active_idx
+ ON task_run (child_session_id)
+ WHERE state IN ('admitted', 'provisioning', 'researching', 'finalizing')
+ `)
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS task_run_parent_state_idx
+ ON task_run (parent_session_id, state, time_updated)
+ `)
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS task_run_root_idx
+ ON task_run (root_run_id)
+ `)
+ yield* tx.run(`
+ CREATE TABLE IF NOT EXISTS task_admission (
+ admission_key TEXT PRIMARY KEY,
+ request_hash TEXT NOT NULL,
+ run_id TEXT NOT NULL REFERENCES task_run(run_id) ON DELETE CASCADE,
+ parent_session_id TEXT NOT NULL REFERENCES session(id) ON DELETE CASCADE,
+ parent_message_id TEXT NOT NULL,
+ tool_call_id TEXT NOT NULL,
+ delivery_mode TEXT NOT NULL CHECK (delivery_mode IN ('foreground', 'background')),
+ time_created INTEGER NOT NULL
+ )
+ `)
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS task_admission_run_idx
+ ON task_admission (run_id)
+ `)
+ yield* tx.run(`
+ CREATE TABLE IF NOT EXISTS task_notification_outbox (
+ id TEXT PRIMARY KEY,
+ run_id TEXT NOT NULL UNIQUE REFERENCES task_run(run_id) ON DELETE CASCADE,
+ message_id TEXT NOT NULL UNIQUE,
+ parent_session_id TEXT NOT NULL REFERENCES session(id) ON DELETE CASCADE,
+ directory TEXT NOT NULL,
+ payload TEXT NOT NULL,
+ status TEXT NOT NULL CHECK (status IN ('pending', 'delivering', 'delivered', 'dead')),
+ attempts INTEGER NOT NULL DEFAULT 0,
+ available_at INTEGER NOT NULL,
+ lease_owner TEXT,
+ lease_expires_at INTEGER,
+ last_error TEXT,
+ time_created INTEGER NOT NULL,
+ time_updated INTEGER NOT NULL,
+ time_delivered INTEGER
+ )
+ `)
+ yield* tx.run(`
+ CREATE INDEX IF NOT EXISTS task_notification_outbox_due_idx
+ ON task_notification_outbox (status, available_at, lease_expires_at)
+ `)
+ })
+ },
+} satisfies DatabaseMigration.Migration
diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts
index c509d4a0..1bdd4971 100644
--- a/packages/core/src/session/sql.ts
+++ b/packages/core/src/session/sql.ts
@@ -13,6 +13,7 @@ import { WorkspaceV2 } from "../workspace"
import { Timestamps } from "../database/schema.sql"
import type { SystemContext } from "../system-context/index"
import { AgentV2 } from "../agent"
+import { sql } from "drizzle-orm"
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
type V1MessageData = Omit
@@ -219,3 +220,100 @@ export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
replacement_seq: integer(),
revision: integer().notNull().default(0),
})
+
+export const TaskRunTable = sqliteTable(
+ "task_run",
+ {
+ run_id: text().primaryKey(),
+ root_run_id: text(),
+ request_hash: text().notNull(),
+ parent_session_id: text()
+ .$type()
+ .notNull()
+ .references(() => SessionTable.id, { onDelete: "cascade" }),
+ parent_message_id: text().$type().notNull(),
+ tool_call_id: text().notNull(),
+ child_session_id: text().$type().notNull(),
+ generation: integer().notNull(),
+ delivery_mode: text().$type<"foreground" | "background">().notNull(),
+ phase: text().$type<"admission" | "research" | "finalize" | "settled">().notNull(),
+ state: text()
+ .$type<
+ "admitted" | "provisioning" | "researching" | "finalizing" | "completed" | "error" | "cancelled" | "interrupted"
+ >()
+ .notNull(),
+ reason: text(),
+ attempts: integer().notNull().default(0),
+ execution_owner: text(),
+ lease_expires_at: integer(),
+ raw_result_message_id: text().$type(),
+ structured_result_message_id: text().$type(),
+ output: text(),
+ error: text({ mode: "json" }).$type<{ code: string; message: string; data?: Record }>(),
+ time_created: integer().notNull(),
+ time_updated: integer().notNull(),
+ time_settled: integer(),
+ },
+ (table) => [
+ uniqueIndex("task_run_child_generation_idx").on(table.child_session_id, table.generation),
+ uniqueIndex("task_run_child_active_idx")
+ .on(table.child_session_id)
+ .where(sql`${table.state} IN ('admitted', 'provisioning', 'researching', 'finalizing')`),
+ index("task_run_parent_state_idx").on(table.parent_session_id, table.state, table.time_updated),
+ index("task_run_root_idx").on(table.root_run_id),
+ ],
+)
+
+export const TaskAdmissionTable = sqliteTable(
+ "task_admission",
+ {
+ admission_key: text().primaryKey(),
+ request_hash: text().notNull(),
+ run_id: text()
+ .notNull()
+ .references(() => TaskRunTable.run_id, { onDelete: "cascade" }),
+ parent_session_id: text()
+ .$type()
+ .notNull()
+ .references(() => SessionTable.id, { onDelete: "cascade" }),
+ parent_message_id: text().$type().notNull(),
+ tool_call_id: text().notNull(),
+ delivery_mode: text().$type<"foreground" | "background">().notNull(),
+ time_created: integer().notNull(),
+ },
+ (table) => [index("task_admission_run_idx").on(table.run_id)],
+)
+
+export const TaskNotificationOutboxTable = sqliteTable(
+ "task_notification_outbox",
+ {
+ id: text().primaryKey(),
+ run_id: text()
+ .notNull()
+ .unique()
+ .references(() => TaskRunTable.run_id, { onDelete: "cascade" }),
+ message_id: text().$type().notNull().unique(),
+ parent_session_id: text()
+ .$type()
+ .notNull()
+ .references(() => SessionTable.id, { onDelete: "cascade" }),
+ directory: DatabasePath.directoryColumn().notNull(),
+ payload: text({ mode: "json" })
+ .$type<{
+ agent: string
+ variant?: string
+ text: string
+ }>()
+ .notNull(),
+ status: text().$type<"pending" | "delivering" | "delivered" | "dead">().notNull(),
+ attempts: integer().notNull().default(0),
+ available_at: integer().notNull(),
+ lease_owner: text(),
+ lease_expires_at: integer(),
+ last_error: text(),
+ time_created: integer().notNull(),
+ time_updated: integer().notNull(),
+ time_delivered: integer(),
+ },
+ (table) => [index("task_notification_outbox_due_idx").on(table.status, table.available_at, table.lease_expires_at)],
+)
diff --git a/packages/core/src/v1/session.ts b/packages/core/src/v1/session.ts
index 48d4556a..7fdc9ee9 100644
--- a/packages/core/src/v1/session.ts
+++ b/packages/core/src/v1/session.ts
@@ -39,6 +39,18 @@ export const StructuredOutputError = NamedError.create("StructuredOutputError",
message: Schema.String,
retries: NonNegativeInt,
})
+export const DoomLoopError = NamedError.create("DoomLoopError", {
+ message: Schema.String,
+ tool: Schema.String,
+ period: NonNegativeInt,
+ count: NonNegativeInt,
+})
+export const TaskBudgetExceededError = NamedError.create("TaskBudgetExceededError", {
+ message: Schema.String,
+ budget: Schema.Literals(["steps", "tokens", "wall_time", "no_progress"]),
+ limit: NonNegativeInt,
+ used: NonNegativeInt,
+})
export const APIError = NamedError.create("APIError", {
message: Schema.String,
statusCode: Schema.optional(NonNegativeInt),
@@ -394,6 +406,8 @@ const AssistantErrorSchema = Schema.Union([
OutputLengthError.EffectSchema,
AbortedError.EffectSchema,
StructuredOutputError.EffectSchema,
+ DoomLoopError.EffectSchema,
+ TaskBudgetExceededError.EffectSchema,
ContextOverflowError.EffectSchema,
APIError.EffectSchema,
OutputDegenerationError.EffectSchema,
diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts
index d7d0b3b0..b0d82bdb 100644
--- a/packages/core/test/database-migration.test.ts
+++ b/packages/core/test/database-migration.test.ts
@@ -74,6 +74,20 @@ describe("DatabaseMigration", () => {
sql`SELECT name, dflt_value FROM pragma_table_info('session_context_epoch') WHERE name = 'agent'`,
),
).toEqual({ name: "agent", dflt_value: "'build'" })
+ expect(
+ yield* db.all(
+ sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('task_run', 'task_admission', 'task_notification_outbox') ORDER BY name`,
+ ),
+ ).toEqual([{ name: "task_admission" }, { name: "task_notification_outbox" }, { name: "task_run" }])
+ expect(
+ yield* db.all(
+ sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('task_run_child_generation_idx', 'task_run_child_active_idx', 'task_notification_outbox_due_idx') ORDER BY name`,
+ ),
+ ).toEqual([
+ { name: "task_notification_outbox_due_idx" },
+ { name: "task_run_child_active_idx" },
+ { name: "task_run_child_generation_idx" },
+ ])
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
expect(
yield* db.all(
diff --git a/packages/deepagent-code/package.json b/packages/deepagent-code/package.json
index a2ebf635..2cac42ec 100644
--- a/packages/deepagent-code/package.json
+++ b/packages/deepagent-code/package.json
@@ -115,6 +115,7 @@
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"ai-gateway-provider": "3.1.2",
+ "ajv": "8.20.0",
"bonjour-service": "1.3.0",
"chokidar": "4.0.3",
"cross-spawn": "catalog:",
diff --git a/packages/deepagent-code/src/agent/agent.ts b/packages/deepagent-code/src/agent/agent.ts
index 66d856d0..78079263 100644
--- a/packages/deepagent-code/src/agent/agent.ts
+++ b/packages/deepagent-code/src/agent/agent.ts
@@ -173,7 +173,8 @@ export const layer = Layer.effect(
// autonomously sets the objective, produces design/plan as needed, and executes it end-to-end.
auto: {
name: "auto",
- description: "Autonomous mode. The agent sets the objective, designs and plans as needed, then executes to completion.",
+ description:
+ "Autonomous mode. The agent sets the objective, designs and plans as needed, then executes to completion.",
options: {},
permission: Permission.merge(
defaults,
@@ -313,6 +314,7 @@ export const layer = Layer.effect(
external_directory: readonlyExternalDirectory,
}),
user,
+ Permission.fromConfig({ doom_loop: "ask" }),
),
description: `Deep sub-module research agent. Use this when you need a decidable explanation of HOW a specific sub-module or subsystem works (not just where it is): its mechanism, key files, outward interfaces, risks, and open questions. Prefer this over "explore" when the task is to understand and report on one module in depth so you can synthesize a plan; prefer "explore" for quick file/keyword location. Returns a structured research result.`,
prompt: PROMPT_RESEARCHER,
@@ -335,6 +337,7 @@ export const layer = Layer.effect(
external_directory: readonlyExternalDirectory,
}),
user,
+ Permission.fromConfig({ doom_loop: "ask" }),
),
description: `Independent, adversarial review agent. Use this to critique a plan or a set of changes from a skeptical, outside perspective — its default stance is that the change has problems. It hunts for correctness bugs, security issues, edge cases, convention conflicts, and missing tests, and reports reproducible failure scenarios. Read-only. Returns structured findings with an overall verdict.`,
prompt: PROMPT_REVIEWER,
@@ -498,14 +501,7 @@ export const layer = Layer.effect(
const list = Effect.fnUntraced(function* () {
const cfg = yield* config.get()
const preferred = cfg.default_agent ? canonicalAgentName(cfg.default_agent) : "auto"
- return pipe(
- agents,
- values(),
- sortBy(
- [(x) => x.name === preferred, "desc"],
- [(x) => x.name, "asc"],
- ),
- )
+ return pipe(agents, values(), sortBy([(x) => x.name === preferred, "desc"], [(x) => x.name, "asc"]))
})
const defaultInfo = Effect.fnUntraced(function* () {
diff --git a/packages/deepagent-code/src/effect/instance-registry.ts b/packages/deepagent-code/src/effect/instance-registry.ts
index 59c556e0..a29ef91e 100644
--- a/packages/deepagent-code/src/effect/instance-registry.ts
+++ b/packages/deepagent-code/src/effect/instance-registry.ts
@@ -1,4 +1,24 @@
+import type { InstanceContext } from "@/project/instance-context"
+
const disposers = new Set<(directory: string) => Promise>()
+const initializers = new Set<(context: InstanceContext) => Promise>()
+
+export function registerInitializer(initializer: (context: InstanceContext) => Promise) {
+ initializers.add(initializer)
+ return () => {
+ initializers.delete(initializer)
+ }
+}
+
+export async function initializeInstance(context: InstanceContext) {
+ const results = await Promise.allSettled([...initializers].map((initializer) => initializer(context)))
+ const failed = results.filter((result): result is PromiseRejectedResult => result.status === "rejected")
+ if (failed.length > 0)
+ throw new AggregateError(
+ failed.map((result) => result.reason),
+ "Instance initialization failed",
+ )
+}
export function registerDisposer(disposer: (directory: string) => Promise) {
disposers.add(disposer)
diff --git a/packages/deepagent-code/src/effect/runtime-flags.ts b/packages/deepagent-code/src/effect/runtime-flags.ts
index 45c100d6..311721fb 100644
--- a/packages/deepagent-code/src/effect/runtime-flags.ts
+++ b/packages/deepagent-code/src/effect/runtime-flags.ts
@@ -57,6 +57,10 @@ export class Service extends ConfigService.Service()("@deepagent-code/R
// v4.0.4 块1 (I33-4): 子 Agent 结果注入父会话的有界长度(字符数)。超过则父只收截断摘要 + 指向子
// session 的引用(全量 text 不丢,仍在子 session 可查)。默认 undefined = 全量注入(逐字节等价现状)。
subagentOutputMaxChars: positiveInteger("DEEPAGENT_CODE_SUBAGENT_OUTPUT_MAX_CHARS"),
+ subagentResearchStepLimit: positiveInteger("DEEPAGENT_CODE_SUBAGENT_RESEARCH_STEP_LIMIT"),
+ subagentResearchTokenLimit: positiveInteger("DEEPAGENT_CODE_SUBAGENT_RESEARCH_TOKEN_LIMIT"),
+ subagentResearchWallMs: positiveInteger("DEEPAGENT_CODE_SUBAGENT_RESEARCH_WALL_MS"),
+ subagentNoProgressLimit: positiveInteger("DEEPAGENT_CODE_SUBAGENT_NO_PROGRESS_LIMIT"),
experimentalLspTy: bool("DEEPAGENT_CODE_EXPERIMENTAL_LSP_TY"),
experimentalLspTool: enabledByExperimental("DEEPAGENT_CODE_EXPERIMENTAL_LSP_TOOL"),
// V3.8 App-A C2.5 (Stage 5): query_log tool — lets the agent retrieve slices of the append-only
diff --git a/packages/deepagent-code/src/project/instance-store.ts b/packages/deepagent-code/src/project/instance-store.ts
index 26db856c..e8ac844a 100644
--- a/packages/deepagent-code/src/project/instance-store.ts
+++ b/packages/deepagent-code/src/project/instance-store.ts
@@ -2,7 +2,7 @@ import { GlobalBus } from "@/bus/global"
import { serviceUse } from "@deepagent-code/core/effect/service-use"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceRef } from "@/effect/instance-ref"
-import { disposeInstance as runDisposers } from "@/effect/instance-registry"
+import { disposeInstance as runDisposers, initializeInstance } from "@/effect/instance-registry"
import { FSUtil } from "@deepagent-code/core/fs-util"
import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
import { assertSafeInstanceRoot, type InstanceContext } from "./instance-context"
@@ -63,6 +63,13 @@ export const layer: Layer.Layer initializeInstance(ctx)).pipe(
+ Effect.catchCause((cause) =>
+ Effect.logWarning("instance initializer failed").pipe(
+ Effect.annotateLogs({ directory: ctx.directory, cause }),
+ ),
+ ),
+ )
return ctx
}).pipe(Effect.withSpan("InstanceStore.boot"))
diff --git a/packages/deepagent-code/src/session/message-v2.ts b/packages/deepagent-code/src/session/message-v2.ts
index cacb518f..7b5210a5 100644
--- a/packages/deepagent-code/src/session/message-v2.ts
+++ b/packages/deepagent-code/src/session/message-v2.ts
@@ -9,12 +9,14 @@ import {
AuthError,
CompactionPart,
ContextOverflowError,
+ DoomLoopError,
Info,
OutputDegenerationError,
OutputLengthError,
Part,
StructuredOutputError,
SubtaskPart,
+ TaskBudgetExceededError,
User,
WithParts,
type ToolPart,
@@ -162,6 +164,13 @@ function providerMeta(metadata: Record | undefined) {
return Object.keys(rest).length > 0 ? rest : undefined
}
+function toolCallProviderMeta(metadata: Record | undefined, differentModel: boolean) {
+ const type = metadata?.deepagent?.toolType
+ if (type !== "custom" && type !== "function") return differentModel ? undefined : providerMeta(metadata)
+ if (!differentModel) return providerMeta(metadata)
+ return { deepagent: { toolType: type } }
+}
+
export const toModelMessagesEffect = Effect.fnUntraced(function* (
input: WithParts[],
model: Provider.Model,
@@ -353,7 +362,9 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
input: part.state.input,
output,
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
- ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
+ ...(toolCallProviderMeta(part.metadata, differentModel)
+ ? { callProviderMetadata: toolCallProviderMeta(part.metadata, differentModel) }
+ : {}),
})
}
if (part.state.status === "error") {
@@ -366,7 +377,9 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
input: part.state.input,
output,
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
- ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
+ ...(toolCallProviderMeta(part.metadata, differentModel)
+ ? { callProviderMetadata: toolCallProviderMeta(part.metadata, differentModel) }
+ : {}),
})
} else {
assistantMessage.parts.push({
@@ -376,7 +389,9 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
input: part.state.input,
errorText: part.state.error,
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
- ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
+ ...(toolCallProviderMeta(part.metadata, differentModel)
+ ? { callProviderMetadata: toolCallProviderMeta(part.metadata, differentModel) }
+ : {}),
})
}
}
@@ -390,7 +405,9 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
input: part.state.input,
errorText: "[Tool execution was interrupted]",
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
- ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
+ ...(toolCallProviderMeta(part.metadata, differentModel)
+ ? { callProviderMetadata: toolCallProviderMeta(part.metadata, differentModel) }
+ : {}),
})
}
if (part.type === "reasoning") {
@@ -656,6 +673,10 @@ export function fromError(
return e
case OutputDegenerationError.isInstance(e):
return e
+ case DoomLoopError.isInstance(e):
+ return e
+ case TaskBudgetExceededError.isInstance(e):
+ return e
case LoadAPIKeyError.isInstance(e):
return new AuthError(
{
diff --git a/packages/deepagent-code/src/session/processor.ts b/packages/deepagent-code/src/session/processor.ts
index 3aca0ed8..346bcacd 100644
--- a/packages/deepagent-code/src/session/processor.ts
+++ b/packages/deepagent-code/src/session/processor.ts
@@ -33,6 +33,7 @@ import * as DateTime from "effect/DateTime"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { toolFileSourceFromUri, Usage, type LLMEvent } from "@deepagent-code/llm"
import { ToolOutput } from "@deepagent-code/core/tool-output"
+import { AgentGateway } from "@deepagent-code/core/agent-gateway"
const DOOM_LOOP_THRESHOLD = 3
const DOOM_LOOP_SEQUENCE_WINDOW = 12
@@ -80,6 +81,23 @@ export class ToolSequenceTracker {
private readonly calls: { fingerprint: string; done: boolean }[] = []
private readonly callIdToIndex = new Map()
private readonly triggeredSequences = new Set()
+ private fingerprintResolver: ((toolName: string, input: unknown) => unknown) | undefined
+ private resultFingerprintResolver: ((toolName: string, result: unknown) => unknown) | undefined
+ private previousResultSignature: string | undefined
+ private previousProgressSignature: string | undefined
+ private equivalentResultCount = 0
+
+ setFingerprintResolver(resolver: (toolName: string, input: unknown) => unknown): void {
+ this.fingerprintResolver = resolver
+ }
+
+ setResultFingerprintResolver(resolver: (toolName: string, result: unknown) => unknown): void {
+ this.resultFingerprintResolver = resolver
+ }
+
+ fingerprint(toolName: string, input: unknown): string {
+ return toolFingerprint(toolName, this.fingerprintResolver ? this.fingerprintResolver(toolName, input) : input)
+ }
/** Record a newly started (running) tool call. */
push(callId: string, fingerprint: string): void {
@@ -101,12 +119,33 @@ export class ToolSequenceTracker {
* settleToolCall so that the "prior calls must be done" invariant holds
* before the next tool starts.
*/
- markDone(callId: string): void {
+ markDone(
+ callId: string,
+ toolName?: string,
+ result?: unknown,
+ progress?: { snapshot: string | undefined; plan: unknown },
+ ): { count: number } | undefined {
const idx = this.callIdToIndex.get(callId)
if (idx !== undefined && idx >= 0 && idx < this.calls.length) {
this.calls[idx].done = true
}
this.callIdToIndex.delete(callId)
+ const resolved = toolName && this.resultFingerprintResolver?.(toolName, result)
+ if (idx === undefined || resolved === undefined) {
+ this.previousResultSignature = undefined
+ this.previousProgressSignature = undefined
+ this.equivalentResultCount = 0
+ return undefined
+ }
+ const resultSignature = `${toolName}:${canonicalJson(resolved)}`
+ const progressSignature = canonicalJson(progress)
+ this.equivalentResultCount =
+ resultSignature === this.previousResultSignature && progressSignature === this.previousProgressSignature
+ ? this.equivalentResultCount + 1
+ : 1
+ this.previousResultSignature = resultSignature
+ this.previousProgressSignature = progressSignature
+ return { count: this.equivalentResultCount }
}
/**
@@ -173,13 +212,13 @@ export class ToolSequenceTracker {
// Detects repetitive/stuck output before it grows unbounded; configurable via
// RuntimeFlags.degenerationDetectorMode ("off" | "shadow" | "enforce").
const DEGENERATION_DETECTOR_VERSION = "1.0"
-const DEGENERATION_ENABLE_THRESHOLD = 20_000 // chars before detection starts
-const DEGENERATION_WINDOW_SIZE = 4_000 // sliding window width in chars
-const DEGENERATION_SAMPLE_INTERVAL = 500 // chars between samples
-const DEGENERATION_N = 4 // N-gram size
-const DEGENERATION_RATIO_THRESHOLD = 0.70 // repeated N-gram fraction
+const DEGENERATION_ENABLE_THRESHOLD = 20_000 // chars before detection starts
+const DEGENERATION_WINDOW_SIZE = 4_000 // sliding window width in chars
+const DEGENERATION_SAMPLE_INTERVAL = 500 // chars between samples
+const DEGENERATION_N = 4 // N-gram size
+const DEGENERATION_RATIO_THRESHOLD = 0.7 // repeated N-gram fraction
const DEGENERATION_SIMILARITY_THRESHOLD = 0.85 // Jaccard threshold between windows
-const DEGENERATION_K = 3 // consecutive samples required
+const DEGENERATION_K = 3 // consecutive samples required
class DegenerationDetector {
private totalChars = 0
@@ -227,9 +266,7 @@ class DegenerationDetector {
// Maintain sliding window: keep only the last WINDOW_SIZE chars
const combined = this.windowText + delta
this.windowText =
- combined.length > DEGENERATION_WINDOW_SIZE
- ? combined.slice(combined.length - DEGENERATION_WINDOW_SIZE)
- : combined
+ combined.length > DEGENERATION_WINDOW_SIZE ? combined.slice(combined.length - DEGENERATION_WINDOW_SIZE) : combined
if (this.totalChars < DEGENERATION_ENABLE_THRESHOLD) return { triggered: false }
if (this.charsSinceLastSample < DEGENERATION_SAMPLE_INTERVAL) return { triggered: false }
@@ -268,7 +305,7 @@ export interface Handle {
output: string
attachments?: SessionV1.FilePart[]
},
- ) => Effect.Effect
+ ) => Effect.Effect
readonly process: (streamInput: LLM.StreamInput) => Effect.Effect
}
@@ -283,6 +320,8 @@ type Input = {
* Absent only in legacy callers that have not been updated yet.
*/
sequenceTracker?: ToolSequenceTracker
+ loopPolicy?: "ask" | "error"
+ noProgressLimit?: number
}
export interface Interface {
@@ -365,13 +404,19 @@ export const layer = Layer.effect(
aborted,
})
- const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) {
+ const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (
+ toolCallID: string,
+ toolName?: string,
+ result?: unknown,
+ progress?: { snapshot: string | undefined; plan: unknown },
+ ) {
// Notify the activity-level tracker that this call has finished so it
// satisfies the "prior calls must be done" precondition for detection.
- ctx.sequenceTracker?.markDone(toolCallID)
+ const noProgress = ctx.sequenceTracker?.markDone(toolCallID, toolName, result, progress)
const done = ctx.toolcalls[toolCallID]?.done
delete ctx.toolcalls[toolCallID]
if (done) yield* Deferred.succeed(done, undefined).pipe(Effect.ignore)
+ return noProgress
})
const ensureV2AssistantMessage = Effect.fn("SessionProcessor.ensureV2AssistantMessage")(function* () {
@@ -456,7 +501,33 @@ export const layer = Layer.effect(
attachments: output.attachments,
},
})
- yield* settleToolCall(toolCallID)
+ const noProgress = yield* settleToolCall(
+ toolCallID,
+ match.part.tool,
+ output,
+ input.noProgressLimit
+ ? {
+ snapshot: yield* snapshot.track(),
+ plan: AgentGateway.DeepAgentSessionState.getPlan(ctx.sessionID),
+ }
+ : undefined,
+ )
+ if (input.noProgressLimit && noProgress && noProgress.count >= input.noProgressLimit) {
+ slog.warn("subagent.loop.detected", {
+ fingerprint_kind: "tool_result",
+ period: 1,
+ count: noProgress.count,
+ tool: match.part.tool,
+ })
+ yield* Effect.fail(
+ new SessionV1.TaskBudgetExceededError({
+ message: `Non-interactive activity stopped after ${noProgress.count} equivalent ${match.part.tool} results without observable progress.`,
+ budget: "no_progress",
+ limit: input.noProgressLimit,
+ used: noProgress.count,
+ }),
+ )
+ }
})
const failToolCall = Effect.fn("SessionProcessor.failToolCall")(function* (toolCallID: string, error: unknown) {
@@ -628,9 +699,7 @@ export const layer = Layer.effect(
// Summary (compaction) processors are excluded — they are short-lived
// and use a distinct reasoning style that should never be circuit-broken.
if (!ctx.assistantMessage.summary) {
- ctx.degenerationDetectors[value.id] = new DegenerationDetector(
- flags.degenerationDetectorMode,
- )
+ ctx.degenerationDetectors[value.id] = new DegenerationDetector(flags.degenerationDetectorMode)
}
yield* session.updatePart(ctx.reasoningMap[value.id])
return
@@ -785,10 +854,26 @@ export const layer = Layer.effect(
// F1: Activity-level cross-message loop detection (primary path)
// ---------------------------------------------------------------
if (ctx.sequenceTracker) {
- const fp = toolFingerprint(value.name, input)
+ const fp = ctx.sequenceTracker.fingerprint(value.name, input)
ctx.sequenceTracker.push(value.id, fp)
const detected = ctx.sequenceTracker.detect()
if (detected && !ctx.sequenceTracker.hasTriggered(detected.sequenceKey)) {
+ slog.warn("subagent.loop.detected", {
+ fingerprint_kind: "tool_input_sequence",
+ period: detected.period,
+ count: detected.count,
+ tool: value.name,
+ })
+ if (ctx.loopPolicy === "error") {
+ return yield* Effect.fail(
+ new SessionV1.DoomLoopError({
+ message: `Non-interactive activity stopped after a repeated ${value.name} tool sequence was detected.`,
+ tool: value.name,
+ period: detected.period,
+ count: detected.count,
+ }),
+ )
+ }
const agent = yield* agents.get(ctx.assistantMessage.agent)
yield* permission.ask({
permission: "doom_loop",
@@ -831,15 +916,23 @@ export const layer = Layer.effect(
!singleRepeat &&
detectRepeatingSequence(
parts
- .filter(
- (part): part is SessionV1.ToolPart =>
- part.type === "tool" && part.state.status !== "pending",
- )
+ .filter((part): part is SessionV1.ToolPart => part.type === "tool" && part.state.status !== "pending")
.map((part) => `${part.tool}:${JSON.stringify(part.state.input)}`),
)
if (!singleRepeat && !sequenceRepeat) return
+ if (ctx.loopPolicy === "error") {
+ return yield* Effect.fail(
+ new SessionV1.DoomLoopError({
+ message: `Non-interactive activity stopped after a repeated ${value.name} tool sequence was detected.`,
+ tool: value.name,
+ period: sequenceRepeat ? 2 : 1,
+ count: DOOM_LOOP_MIN_REPEATS,
+ }),
+ )
+ }
+
const agent = yield* agents.get(ctx.assistantMessage.agent)
yield* permission.ask({
permission: "doom_loop",
@@ -1006,9 +1099,9 @@ export const layer = Layer.effect(
// Response-side prompt-cache monitor: compare this step's real cache-read ratio to the
// previous step and warn if it collapsed while the prompt didn't shrink (suspected cache
// break the static system-hash tripwire can't see). Diagnostic only; never throws.
- yield* Effect.sync(() =>
- LLMRequestPrep.recordCacheHitOutcome(ctx.sessionID, usage.tokens),
- ).pipe(Effect.ignore)
+ yield* Effect.sync(() => LLMRequestPrep.recordCacheHitOutcome(ctx.sessionID, usage.tokens)).pipe(
+ Effect.ignore,
+ )
if (!ctx.assistantMessage.summary) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
if (mirrorAssistant) {
@@ -1197,12 +1290,16 @@ export const layer = Layer.effect(
const match = yield* readToolCall(toolCallID)
if (!match) continue
const part = match.part
+ const incompleteInput = part.state.status === "pending"
+ const toolError = incompleteInput
+ ? "Tool input was incomplete and was not executed"
+ : "Tool execution aborted"
if (mirrorAssistant && match.call.assistantMessageID) {
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: ctx.sessionID,
assistantMessageID: match.call.assistantMessageID,
callID: toolCallID,
- error: { type: "unknown", message: "Tool execution aborted" },
+ error: { type: "unknown", message: toolError },
provider: { executed: part.metadata?.providerExecuted === true },
timestamp: DateTime.makeUnsafe(Date.now()),
})
@@ -1214,8 +1311,8 @@ export const layer = Layer.effect(
state: {
...part.state,
status: "error",
- error: "Tool execution aborted",
- metadata: { ...metadata, interrupted: true },
+ error: toolError,
+ metadata: { ...metadata, interrupted: true, incompleteInput },
time: { start: "time" in part.state ? part.state.time.start : end, end },
},
})
diff --git a/packages/deepagent-code/src/session/prompt.ts b/packages/deepagent-code/src/session/prompt.ts
index 6d36fb83..313f5c04 100644
--- a/packages/deepagent-code/src/session/prompt.ts
+++ b/packages/deepagent-code/src/session/prompt.ts
@@ -61,10 +61,25 @@ import { Truncate } from "@/tool/truncate"
import { Image } from "@/image/image"
import { decodeDataUrl } from "@/util/data-url"
import { Process } from "@/util/process"
-import { Cause, Data, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect"
+import {
+ Cause,
+ Context,
+ Data,
+ Duration,
+ Effect,
+ Exit,
+ Fiber,
+ Latch,
+ Layer,
+ Option,
+ Schedule,
+ Schema,
+ Scope,
+ Types,
+} from "effect"
import * as EffectLogger from "@deepagent-code/core/effect/logger"
import { InstanceState } from "@/effect/instance-state"
-import { TaskTool, type TaskPromptOps } from "@/tool/task"
+import { projectRecoveredSubagentRun, TaskTool, type TaskPromptOps } from "@/tool/task"
import { SessionRunState } from "./run-state"
import { SessionSteer } from "./steer"
import { writeGovernanceAudit } from "./goal-governance-audit"
@@ -94,6 +109,10 @@ import { LLMEvent } from "@deepagent-code/llm"
import { ConversationLogWriter } from "./conversation-log-writer"
import { collectVolatileFacts, refreshWorldState } from "./context-ledger"
import { CodeIndexTrigger } from "./code-index-trigger"
+import { ToolSemanticFingerprint } from "@/tool/semantic-fingerprint"
+import { deliverTaskNotifications, recoverExpiredTaskRuns } from "@/tool/task-run"
+import { registerDisposer, registerInitializer } from "@/effect/instance-registry"
+import { InstanceRef } from "@/effect/instance-ref"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -139,6 +158,41 @@ function isOrphanedInterruptedTool(part: SessionV1.ToolPart) {
return part.state.status === "error" && part.state.metadata?.interrupted === true
}
+function isStructuredFinalizer(metadata: unknown) {
+ if (!isRecord(metadata)) return false
+ if (!isRecord(metadata.deepagent)) return false
+ return isRecord(metadata.deepagent.structured_finalizer)
+}
+
+function noninteractiveTaskActivity(metadata: unknown) {
+ if (!isRecord(metadata)) return false
+ if (!isRecord(metadata.deepagent)) return undefined
+ if (!isRecord(metadata.deepagent.task_activity)) return undefined
+ const activity = metadata.deepagent.task_activity
+ if (activity.interactive !== false) return undefined
+ if (!isRecord(activity.budget)) return { interactive: false as const }
+ const positive = (value: unknown) =>
+ typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined
+ return {
+ interactive: false as const,
+ startedAt: positive(activity.started_at),
+ maxSteps: positive(activity.budget.max_steps),
+ maxTokens: positive(activity.budget.max_tokens),
+ maxWallMs: positive(activity.budget.max_wall_ms),
+ maxNoProgress: positive(activity.budget.max_no_progress),
+ }
+}
+
+function taskNotification(metadata: unknown) {
+ if (!isRecord(metadata)) return undefined
+ if (!isRecord(metadata.deepagent)) return undefined
+ if (!isRecord(metadata.deepagent.task_notification)) return undefined
+ const runID = metadata.deepagent.task_notification.run_id
+ const outboxID = metadata.deepagent.task_notification.outbox_id
+ if (typeof runID !== "string" || typeof outboxID !== "string") return undefined
+ return { runID, outboxID }
+}
+
// §S1.2 — a goal in one of these phases is no longer ticking, so a "goal_steer" would never be drained.
// promptOrSteer routes to the plain "steer" channel (or a fresh turn) instead. Mirrors goal-manager's
// isTerminalGoalPhase (kept as a local const to avoid a circular import: goal-manager imports this file).
@@ -149,13 +203,9 @@ class InvalidInput extends Data.TaggedError("SessionPrompt.InvalidInput")<{ read
// §S1.2 — convert PromptInput parts to the durable Prompt model used by the steer buffer.
// All part types that have a Prompt equivalent are preserved; subtask parts are explicitly rejected
// so they never produce a silent empty steer. The steer caller should surface this as a client error.
-const promptInputToPrompt = (
- parts: PromptInput["parts"],
-): Effect.Effect => {
+const promptInputToPrompt = (parts: PromptInput["parts"]): Effect.Effect => {
if (parts.some((p) => p.type === "subtask"))
- return Effect.fail(
- new InvalidInput({ message: "Subtask prompt parts cannot be steered while a session is busy" }),
- )
+ return Effect.fail(new InvalidInput({ message: "Subtask prompt parts cannot be steered while a session is busy" }))
const text = parts
.filter((p): p is Extract => p.type === "text")
.map((p) => p.text)
@@ -175,9 +225,7 @@ const promptInputToPrompt = (
.filter((p): p is Extract => p.type === "agent")
.map((p) => new AgentAttachment({ name: p.name }))
if (text.length === 0 && files.length === 0 && agents.length === 0)
- return Effect.fail(
- new InvalidInput({ message: "Steer prompt must contain at least one supported part" }),
- )
+ return Effect.fail(new InvalidInput({ message: "Steer prompt must contain at least one supported part" }))
return Effect.succeed(
Prompt.fromUserMessage({
text,
@@ -1695,6 +1743,25 @@ export const layer = Layer.effect(
const prompt: (input: PromptInput) => Effect.Effect = Effect.fn(
"SessionPrompt.prompt",
)(function* (input: PromptInput) {
+ const notification = taskNotification(input.metadata)
+ if (notification && input.messageID) {
+ const existing = yield* MessageV2.get({ sessionID: input.sessionID, messageID: input.messageID }).pipe(
+ Effect.provideService(Database.Service, database),
+ Effect.catchCause(() => Effect.succeed(undefined)),
+ )
+ if (existing) {
+ const persisted = existing.info.role === "user" ? taskNotification(existing.info.metadata) : undefined
+ if (
+ existing.info.role !== "user" ||
+ persisted?.runID !== notification.runID ||
+ persisted.outboxID !== notification.outboxID
+ )
+ return yield* Effect.die(
+ new Error(`Task notification message ID ${input.messageID} conflicts with persisted content`),
+ )
+ return existing
+ }
+ }
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
yield* revert.cleanup(session)
const pipeline = yield* buildPromptPipelineSubmission(input)
@@ -1722,6 +1789,7 @@ export const layer = Layer.effect(
if (input.noReply === true) return message
const first = yield* loop({ sessionID: input.sessionID })
+ if (isStructuredFinalizer(input.metadata)) return first
// V3 Plan A: mode-driven multi-round autonomous loop for high/max/ultra. It remains
// fail-closed (any error -> the single-turn result). Real validation (A3),
// git rollback (A5), revise turn, and the A3 macro-round suggestion are wired.
@@ -1969,8 +2037,7 @@ export const layer = Layer.effect(
? {
providerID: ProviderV2.ID.make(current.model.providerID),
modelID: ModelV2.ID.make(current.model.id),
- variant:
- current.model.variant && current.model.variant !== "default" ? current.model.variant : undefined,
+ variant: current.model.variant && current.model.variant !== "default" ? current.model.variant : undefined,
}
: yield* currentModel(sessionID)
const variant = "variant" in resolved ? resolved.variant : undefined
@@ -2042,15 +2109,13 @@ export const layer = Layer.effect(
return Option.getOrElse(decodeSoftLanding(raw), () => initialSoftLandingState)
})
- const writeSoftLandingState: (
- sessionID: SessionID,
- state: CompactionSoftLandingState,
- ) => Effect.Effect = Effect.fn("SessionPrompt.writeSoftLandingState")(function* (sessionID, state) {
- const session = yield* sessions.get(sessionID).pipe(Effect.orElseSucceed(() => undefined))
- // Merge into existing metadata so we never clobber a co-tenant key.
- const metadata = { ...(session?.metadata ?? {}), [SOFT_LANDING_METADATA_KEY]: state }
- yield* sessions.setMetadata({ sessionID, metadata }).pipe(Effect.ignore)
- })
+ const writeSoftLandingState: (sessionID: SessionID, state: CompactionSoftLandingState) => Effect.Effect =
+ Effect.fn("SessionPrompt.writeSoftLandingState")(function* (sessionID, state) {
+ const session = yield* sessions.get(sessionID).pipe(Effect.orElseSucceed(() => undefined))
+ // Merge into existing metadata so we never clobber a co-tenant key.
+ const metadata = { ...(session?.metadata ?? {}), [SOFT_LANDING_METADATA_KEY]: state }
+ yield* sessions.setMetadata({ sessionID, metadata }).pipe(Effect.ignore)
+ })
// reminder (soft line): a lightweight, non-compacting tail nudge asking the model to persist key
// decisions/findings into the plan's evidence/worklog. Reuses the SAME tail-user-message channel as
@@ -2068,29 +2133,26 @@ export const layer = Layer.effect(
text: string,
model: { providerID: ProviderV2.ID; modelID: ModelV2.ID },
agentName: string,
- ) => Effect.Effect = Effect.fn("SessionPrompt.injectTailReminder")(function* (
- sessionID,
- text,
- model,
- agentName,
- ) {
- const msg = yield* sessions.updateMessage({
- id: MessageID.ascending(),
- role: "user",
- sessionID,
- agent: agentName,
- model,
- time: { created: Date.now() },
- })
- yield* sessions.updatePart({
- id: PartID.ascending(),
- messageID: msg.id,
- sessionID,
- type: "text",
- synthetic: true,
- text,
- })
- })
+ ) => Effect.Effect = Effect.fn("SessionPrompt.injectTailReminder")(
+ function* (sessionID, text, model, agentName) {
+ const msg = yield* sessions.updateMessage({
+ id: MessageID.ascending(),
+ role: "user",
+ sessionID,
+ agent: agentName,
+ model,
+ time: { created: Date.now() },
+ })
+ yield* sessions.updatePart({
+ id: PartID.ascending(),
+ messageID: msg.id,
+ sessionID,
+ type: "text",
+ synthetic: true,
+ text,
+ })
+ },
+ )
// fallback ("临终笔记" line): the last chance before a hard compaction. All tools stay available so
// the model can call the plan-edit tool to固化 un-persisted state. Under a goal (loop/design) mode we
@@ -2123,6 +2185,14 @@ export const layer = Layer.effect(
"",
].join("\n")
+ const TOOL_INPUT_CONTINUE_TAIL_TEXT = [
+ "",
+ "你上一轮的工具输入因达到输出长度上限而被截断,系统没有执行该工具,也没有应用其中的文件修改。",
+ "不要原样重发同一个大型 JSON 工具调用。将修改拆小;对于大型 write/edit/apply_patch,改用 `apply_patch_chunk`,",
+ "每个 patchText 块不超过 12000 UTF-8 字节(中文建议不超过约 4000 字)。begin 使用 offset 0;之后每次 append 和最终 commit 都使用上一结果返回的 nextOffset。",
+ "",
+ ].join("\n")
+
// V4.0.1 P1 (§3.3) — post-hard-compaction World State re-injection. After a hard compaction the
// (now-narrowed) summary deliberately dropped file/env/diagnostics; this re-injects their LATEST
// values as a TAIL user block (reuses the SAME injectTailReminder primitive — never the static system
@@ -2134,17 +2204,14 @@ export const layer = Layer.effect(
workspacePath: string | undefined,
model: { providerID: ProviderV2.ID; modelID: ModelV2.ID },
agentName: string,
- ) => Effect.Effect = Effect.fn("SessionPrompt.injectWorldStateTail")(function* (
- sessionID,
- workspacePath,
- model,
- agentName,
- ) {
- if (!workspacePath) return
- const facts = yield* collectVolatileFacts(workspacePath)
- const rendered = yield* refreshWorldState({ workspacePath, facts })
- if (rendered.trim().length > 0) yield* injectTailReminder(sessionID, rendered, model, agentName)
- })
+ ) => Effect.Effect = Effect.fn("SessionPrompt.injectWorldStateTail")(
+ function* (sessionID, workspacePath, model, agentName) {
+ if (!workspacePath) return
+ const facts = yield* collectVolatileFacts(workspacePath)
+ const rendered = yield* refreshWorldState({ workspacePath, facts })
+ if (rendered.trim().length > 0) yield* injectTailReminder(sessionID, rendered, model, agentName)
+ },
+ )
const runLoop: (sessionID: SessionID, drainFirst?: boolean) => Effect.Effect = Effect.fn(
"SessionPrompt.run",
@@ -2183,7 +2250,30 @@ export const layer = Layer.effect(
// V3.9 §A: `lsp` enables the AST symbol pass (symbol nodes + imports/calls edges) over the
// content-sha-changed files; a language with no LSP client degrades to the file-level view.
// SEAM: incremental mtime-gated fs-walking is the remaining follow-up (see code-index-trigger.ts).
- if (!indexedSessions.has(sessionID)) {
+ const initialMessages = yield* MessageV2.filterCompactedEffect(sessionID).pipe(
+ Effect.provideService(Database.Service, database),
+ )
+ const initialUser = MessageV2.latest(initialMessages).user
+ const initialFinalizer = isStructuredFinalizer(initialUser?.metadata)
+ const taskActivity = noninteractiveTaskActivity(initialUser?.metadata) || undefined
+ const failTaskBudget = Effect.fn("SessionPrompt.failTaskBudget")(function* (
+ assistant: SessionV1.Assistant,
+ budget: "steps" | "tokens" | "wall_time",
+ limit: number,
+ used: number,
+ ) {
+ assistant.error = new SessionV1.TaskBudgetExceededError({
+ message: `Subagent research ${budget} budget exhausted (${used}/${limit}).`,
+ budget,
+ limit,
+ used,
+ }).toObject()
+ assistant.finish = "error"
+ assistant.time.completed = Date.now()
+ yield* sessions.updateMessage(assistant)
+ yield* slog.warn("subagent.research.failed", { reason: "budget_exhausted", budget, limit, used })
+ })
+ if (!initialFinalizer && !indexedSessions.has(sessionID)) {
indexedSessions.add(sessionID)
yield* CodeIndexTrigger.indexWorkspace({ workspacePath: ctx.directory, fsys, lsp }).pipe(
Effect.asVoid,
@@ -2218,10 +2308,31 @@ export const layer = Layer.effect(
const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = MessageV2.latest(msgs)
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
+ const finalizerMode = isStructuredFinalizer(lastUser.metadata)
const lastAssistantMsg = msgs.findLast(
(msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,
)
+ const tokenUsage = taskActivity
+ ? msgs
+ .filter(
+ (item): item is SessionV1.WithParts & { info: SessionV1.Assistant } =>
+ item.info.role === "assistant" && (!initialUser || item.info.id > initialUser.id),
+ )
+ .reduce(
+ (sum, item) => sum + item.info.tokens.input + item.info.tokens.output + item.info.tokens.reasoning,
+ 0,
+ )
+ : 0
+ const elapsed = taskActivity?.startedAt ? Math.max(0, Date.now() - taskActivity.startedAt) : 0
+ if (lastAssistant && taskActivity?.maxTokens && tokenUsage >= taskActivity.maxTokens) {
+ yield* failTaskBudget(lastAssistant, "tokens", taskActivity.maxTokens, tokenUsage)
+ break
+ }
+ if (lastAssistant && taskActivity?.maxWallMs && elapsed >= taskActivity.maxWallMs) {
+ yield* failTaskBudget(lastAssistant, "wall_time", taskActivity.maxWallMs, elapsed)
+ break
+ }
// Some providers return "stop" even when the assistant message contains
// tool calls. Keep the loop running so tool results can be sent back to
// the model, but ignore cleanup-marked interrupted orphans.
@@ -2229,6 +2340,9 @@ export const layer = Layer.effect(
lastAssistantMsg?.parts.some(
(part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),
) ?? false
+ const orphan = lastAssistantMsg?.parts.find(
+ (part): part is SessionV1.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part),
+ )
if (
lastAssistant?.finish &&
@@ -2248,18 +2362,24 @@ export const layer = Layer.effect(
const done = sls.outputContinuationCount ?? 0
if (done < outputContinuationMax()) {
yield* writeSoftLandingState(sessionID, { ...sls, outputContinuationCount: done + 1 })
- yield* injectTailReminder(sessionID, OUTPUT_CONTINUE_TAIL_TEXT, lastUser.model, lastUser.agent)
+ yield* injectTailReminder(
+ sessionID,
+ orphan?.state.status === "error" && orphan.state.metadata?.incompleteInput === true
+ ? TOOL_INPUT_CONTINUE_TAIL_TEXT
+ : OUTPUT_CONTINUE_TAIL_TEXT,
+ lastUser.model,
+ lastUser.agent,
+ )
yield* slog.info("output soft-landing: continuing after length cutoff", {
continuation: done + 1,
max: outputContinuationMax(),
})
continue
}
- yield* slog.warn("output soft-landing: continuation cap reached, ending turn", { max: outputContinuationMax() })
+ yield* slog.warn("output soft-landing: continuation cap reached, ending turn", {
+ max: outputContinuationMax(),
+ })
}
- const orphan = lastAssistantMsg?.parts.find(
- (part): part is SessionV1.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part),
- )
if (orphan) {
yield* slog.warn("loop exit with orphaned interrupted tool", {
messageID: lastAssistant.id,
@@ -2272,14 +2392,18 @@ export const layer = Layer.effect(
}
// Output soft-landing: a natural stop (or any non-length finish that keeps looping via tool
// calls) resets the consecutive-continuation run so a later length cutoff gets the full budget.
- if (flags.outputSoftLanding && lastAssistant?.finish && lastAssistant.finish !== "length") {
+ if (!finalizerMode && flags.outputSoftLanding && lastAssistant?.finish && lastAssistant.finish !== "length") {
const sls = yield* readSoftLandingState(sessionID)
if ((sls.outputContinuationCount ?? 0) !== 0)
yield* writeSoftLandingState(sessionID, { ...sls, outputContinuationCount: 0 })
}
step++
- if (step === 1) {
+ if (lastAssistant && taskActivity?.maxSteps && step > taskActivity.maxSteps) {
+ yield* failTaskBudget(lastAssistant, "steps", taskActivity.maxSteps, step - 1)
+ break
+ }
+ if (step === 1 && !finalizerMode) {
yield* title({
session,
modelID: lastUser.model.modelID,
@@ -2290,7 +2414,8 @@ export const layer = Layer.effect(
}
const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID)
- const task = tasks.pop()
+ const finalizerDecision = finalizerMode ? LLM.finalizerCapability(model) : undefined
+ const task = finalizerMode ? undefined : tasks.pop()
if (task?.type === "subtask") {
yield* handleSubtask({ task, model, lastUser, sessionID, session, msgs })
@@ -2315,7 +2440,7 @@ export const layer = Layer.effect(
// "临终笔记" fallback (all tools retained), then the SAME hard compaction. `phase === "hard"` is
// exactly `isOverflow`, and the reminder/fallback layers never move the hard line, so the
// compaction trigger is unchanged.
- if (lastFinished && lastFinished.summary !== true) {
+ if (!finalizerMode && lastFinished && lastFinished.summary !== true) {
if (!flags.softLandingCompaction) {
if (yield* compaction.isOverflow({ tokens: lastFinished.tokens, model })) {
yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true })
@@ -2386,13 +2511,15 @@ export const layer = Layer.effect(
yield* events.publish(Session.Event.Error, { sessionID, error: error.toObject() })
throw error
}
- const maxSteps = agent.steps ?? Infinity
+ const maxSteps = Math.min(agent.steps ?? Infinity, taskActivity?.maxSteps ?? Infinity)
const isLastStep = step >= maxSteps
- msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe(
- Effect.provideService(RuntimeFlags.Service, flags),
- Effect.provideService(FSUtil.Service, fsys),
- Effect.provideService(Session.Service, sessions),
- )
+ if (!finalizerMode) {
+ msgs = yield* SessionReminders.apply({ messages: msgs, agent, session }).pipe(
+ Effect.provideService(RuntimeFlags.Service, flags),
+ Effect.provideService(FSUtil.Service, fsys),
+ Effect.provideService(Session.Service, sessions),
+ )
+ }
const msg: SessionV1.Assistant = {
id: MessageID.ascending(),
@@ -2411,6 +2538,20 @@ export const layer = Layer.effect(
}
yield* sessions.updateMessage(msg)
+ if (finalizerDecision?.capability === "unsupported") {
+ msg.error = new NamedError.Unknown({
+ message: `[${finalizerDecision.reason}] Structured finalization requires tool-call capability.`,
+ }).toObject()
+ msg.finish = "error"
+ msg.time.completed = Date.now()
+ yield* sessions.updateMessage(msg)
+ yield* slog.warn("subagent.finalize.failed", {
+ reason: finalizerDecision.reason,
+ protocol: finalizerDecision.protocol,
+ })
+ break
+ }
+
const finalizeInterruptedAssistant = Effect.gen(function* () {
if (msg.time.completed) return
msg.error ??= MessageV2.fromError(new DOMException("Aborted", "AbortError"), {
@@ -2427,6 +2568,8 @@ export const layer = Layer.effect(
sessionID,
model,
sequenceTracker: toolSequenceTracker,
+ loopPolicy: finalizerMode || taskActivity ? "error" : "ask",
+ noProgressLimit: taskActivity?.maxNoProgress,
})
.pipe(Effect.onInterrupt(() => finalizeInterruptedAssistant))
@@ -2435,22 +2578,24 @@ export const layer = Layer.effect(
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
const promptOps = yield* ops()
- const tools = yield* SessionTools.resolve({
- agent,
- session,
- model,
- processor: handle,
- bypassAgentCheck,
- messages: msgs,
- promptOps,
- }).pipe(
- Effect.provideService(Plugin.Service, plugin),
- Effect.provideService(Permission.Service, permission),
- Effect.provideService(ToolRegistry.Service, registry),
- Effect.provideService(MCP.Service, mcp),
- Effect.provideService(Truncate.Service, truncate),
- Effect.provideService(RuntimeFlags.Service, flags),
- )
+ const tools: Record = finalizerMode
+ ? {}
+ : yield* SessionTools.resolve({
+ agent,
+ session,
+ model,
+ processor: handle,
+ bypassAgentCheck,
+ messages: msgs,
+ promptOps,
+ }).pipe(
+ Effect.provideService(Plugin.Service, plugin),
+ Effect.provideService(Permission.Service, permission),
+ Effect.provideService(ToolRegistry.Service, registry),
+ Effect.provideService(MCP.Service, mcp),
+ Effect.provideService(Truncate.Service, truncate),
+ Effect.provideService(RuntimeFlags.Service, flags),
+ )
if (lastUser.format?.type === "json_schema") {
tools["StructuredOutput"] = createStructuredOutputTool({
@@ -2460,8 +2605,14 @@ export const layer = Layer.effect(
},
})
}
+ toolSequenceTracker.setFingerprintResolver((toolName, args) =>
+ ToolSemanticFingerprint.resolve(tools[toolName], args),
+ )
+ toolSequenceTracker.setResultFingerprintResolver((toolName, result) =>
+ ToolSemanticFingerprint.resolveResult(tools[toolName], result),
+ )
- if (step === 1)
+ if (step === 1 && !finalizerMode)
yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope))
if (step > 1 && lastFinished) {
@@ -2482,7 +2633,7 @@ export const layer = Layer.effect(
}
}
- yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
+ if (!finalizerMode) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
// PR-1: Compute terminal boundary for reasoning model-view projection.
// The most recent settled assistant message (has finish, no pending tool calls)
@@ -2503,18 +2654,29 @@ export const layer = Layer.effect(
}
}
- const [skills, env, instructions, modelMsgs] = yield* Effect.all([
- sys.skills(agent),
- sys.environment(model),
- instruction.system().pipe(Effect.orDie),
- MessageV2.toModelMessagesEffect(msgs, model, { terminalBoundaryID }),
- ])
- const system = [...env, ...instructions, ...(skills ? [skills] : [])]
const format = lastUser.format ?? { type: "text" as const }
+ const modelMsgs = yield* MessageV2.toModelMessagesEffect(
+ finalizerMode ? msgs.filter((item) => item.info.id === lastUser.id) : msgs,
+ model,
+ { terminalBoundaryID },
+ )
+ const system = finalizerMode
+ ? [
+ buildStructuredOutputSystemPrompt(format.type === "json_schema" ? format.schema : {}),
+ "This is a bounded finalizer turn. Read the supplied research result and call StructuredOutput once. No research or other work is permitted.",
+ ]
+ : yield* Effect.all([
+ sys.skills(agent),
+ sys.environment(model),
+ instruction.system().pipe(Effect.orDie),
+ ]).pipe(
+ Effect.map(([skills, env, instructions]) => [...env, ...instructions, ...(skills ? [skills] : [])]),
+ )
// P1: inject schema-aware prompt so the model knows the exact field names even
// during extended-thinking (xhigh) reasoning where the tool definition may not
// be immediately visible when the model starts generating its thinking tokens.
- if (format.type === "json_schema") system.push(buildStructuredOutputSystemPrompt(format.schema))
+ if (!finalizerMode && format.type === "json_schema")
+ system.push(buildStructuredOutputSystemPrompt(format.schema))
const result = yield* handle.process({
user: lastUser,
agent,
@@ -2525,7 +2687,8 @@ export const layer = Layer.effect(
messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])],
tools,
model,
- toolChoice: format.type === "json_schema" ? "required" : undefined,
+ toolChoice: finalizerDecision?.toolChoice ?? (format.type === "json_schema" ? "required" : undefined),
+ reasoning: finalizerDecision?.reasoning,
})
if (structured !== undefined) {
@@ -2535,6 +2698,17 @@ export const layer = Layer.effect(
return "break" as const
}
+ if (finalizerMode) {
+ if (!handle.message.error) {
+ handle.message.error = new SessionV1.StructuredOutputError({
+ message: "Finalizer did not produce valid structured output",
+ retries: 1,
+ }).toObject()
+ yield* sessions.updateMessage(handle.message)
+ }
+ return "break" as const
+ }
+
const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish)
if (finished && !handle.message.error) {
if (format.type === "json_schema") {
@@ -2567,9 +2741,8 @@ export const layer = Layer.effect(
const currentAssistantMsg = latestMsgs.findLast(
(m) => m.info.role === "assistant" && m.info.id === handle.message.id,
)
- const hadStructuredOutputCall = currentAssistantMsg?.parts.some(
- (p) => p.type === "tool" && p.tool === "StructuredOutput",
- ) ?? false
+ const hadStructuredOutputCall =
+ currentAssistantMsg?.parts.some((p) => p.type === "tool" && p.tool === "StructuredOutput") ?? false
if (hadStructuredOutputCall) {
const retryMax = format.retryCount ?? 2
@@ -2637,7 +2810,7 @@ export const layer = Layer.effect(
// it (codex: needsFollowUp = modelSaidContinue || pendingInput-nonempty). A non-consuming peek;
// the actual drain (consume-once) happens at the next iteration's top. Gated by the flag.
if (outcome === "break") {
- if (flags.v4Steering && (yield* steerBuffer.hasPending(sessionID))) {
+ if (!finalizerMode && flags.v4Steering && (yield* steerBuffer.hasPending(sessionID))) {
yield* slog.info("steer pending at model boundary, continuing to absorb")
continue
}
@@ -2668,22 +2841,22 @@ export const layer = Layer.effect(
prompt: Prompt
delivery?: SessionSteer.Delivery
messageID?: SessionMessage.ID
- }) => Effect.Effect = Effect.fn(
- "SessionPrompt.steer",
- )(function* (input) {
+ }) => Effect.Effect = Effect.fn("SessionPrompt.steer")(function* (input) {
if (!flags.v4Steering)
return yield* Effect.die(new NamedError.Unknown({ message: "Steering is disabled (v4Steering=false)" }))
const delivery = input.delivery ?? "steer"
- const admitted = yield* steerBuffer.admit({
- sessionID: input.sessionID,
- prompt: input.prompt,
- delivery,
- correlationID: input.messageID,
- }).pipe(
- Effect.catchTag("SessionSteer.CorrelationConflict", () =>
- Effect.die(new NamedError.Unknown({ message: "Steer correlation conflict: duplicate follow-up" })),
- ),
- )
+ const admitted = yield* steerBuffer
+ .admit({
+ sessionID: input.sessionID,
+ prompt: input.prompt,
+ delivery,
+ correlationID: input.messageID,
+ })
+ .pipe(
+ Effect.catchTag("SessionSteer.CorrelationConflict", () =>
+ Effect.die(new NamedError.Unknown({ message: "Steer correlation conflict: duplicate follow-up" })),
+ ),
+ )
yield* elog.info("steer admitted", {
sessionID: input.sessionID,
messageID: admitted.id,
@@ -2721,9 +2894,7 @@ export const layer = Layer.effect(
const goalActive = goal != null && !TERMINAL_GOAL_PHASES.has(goal.phase)
if (goalActive) {
const steerPrompt = yield* promptInputToPrompt(input.parts).pipe(
- Effect.catchTag("SessionPrompt.InvalidInput", (e) =>
- Effect.die(e),
- ),
+ Effect.catchTag("SessionPrompt.InvalidInput", (e) => Effect.die(e)),
)
const admitted = yield* steer({
sessionID: input.sessionID,
@@ -2746,9 +2917,7 @@ export const layer = Layer.effect(
return { kind: "turn" as const, message }
}
const steerPrompt = yield* promptInputToPrompt(input.parts).pipe(
- Effect.catchTag("SessionPrompt.InvalidInput", (e) =>
- Effect.die(e),
- ),
+ Effect.catchTag("SessionPrompt.InvalidInput", (e) => Effect.die(e)),
)
const admitted = yield* steer({
sessionID: input.sessionID,
@@ -2895,6 +3064,71 @@ export const layer = Layer.effect(
return result
})
+ const notificationWorkers = new Map>()
+ const startNotificationWorker = registerInitializer((ctx) =>
+ Effect.runPromise(
+ Effect.gen(function* () {
+ if (notificationWorkers.has(ctx.directory)) return
+ const owner = `task-notification:${process.pid}:${randomUUID()}`
+ const pump = recoverExpiredTaskRuns({ directory: ctx.directory }).pipe(
+ Effect.tap((runs) =>
+ Effect.forEach(
+ runs,
+ (run) => projectRecoveredSubagentRun(sessions, run).pipe(Effect.provideService(InstanceRef, ctx)),
+ { discard: true },
+ ),
+ ),
+ Effect.flatMap(() =>
+ deliverTaskNotifications({
+ owner,
+ directory: ctx.directory,
+ limit: 1,
+ deliver: (item) =>
+ prompt({
+ messageID: item.messageID,
+ sessionID: item.parentSessionID,
+ agent: item.payload.agent,
+ variant: item.payload.variant,
+ metadata: {
+ deepagent: {
+ task_notification: { run_id: item.runID, outbox_id: item.id },
+ },
+ },
+ parts: [{ type: "text", synthetic: true, text: item.payload.text }],
+ }).pipe(Effect.provideService(InstanceRef, ctx), Effect.asVoid),
+ }),
+ ),
+ Effect.provideService(Database.Service, database),
+ Effect.catchCause((cause) =>
+ Effect.sync(() =>
+ log.error("task notification pump failed", { directory: ctx.directory, cause: Cause.pretty(cause) }),
+ ).pipe(Effect.as([])),
+ ),
+ )
+ const worker = yield* pump.pipe(
+ Effect.repeat(Schedule.spaced(Duration.seconds(2))),
+ Effect.asVoid,
+ Effect.forkIn(scope),
+ )
+ notificationWorkers.set(ctx.directory, worker)
+ }),
+ ),
+ )
+ const stopNotificationWorker = registerDisposer((directory) => {
+ const worker = notificationWorkers.get(directory)
+ if (!worker) return Promise.resolve()
+ notificationWorkers.delete(directory)
+ return Effect.runPromise(Fiber.interrupt(worker).pipe(Effect.asVoid))
+ })
+ yield* Effect.addFinalizer(() =>
+ Effect.gen(function* () {
+ startNotificationWorker()
+ stopNotificationWorker()
+ yield* Effect.forEach(notificationWorkers.values(), Fiber.interrupt, { discard: true })
+ notificationWorkers.clear()
+ }),
+ )
+
return Service.of({
cancel,
prompt,
diff --git a/packages/deepagent-code/src/session/session.ts b/packages/deepagent-code/src/session/session.ts
index 430d7b7f..58b51133 100644
--- a/packages/deepagent-code/src/session/session.ts
+++ b/packages/deepagent-code/src/session/session.ts
@@ -487,6 +487,7 @@ export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect
readonly listGlobal: (input?: GlobalListInput) => Effect.Effect
readonly create: (input?: {
+ id?: SessionID
parentID?: SessionID
title?: string
agent?: string
@@ -740,6 +741,7 @@ export const layer: Layer.Layer<
})
const create = Effect.fn("Session.create")(function* (input?: {
+ id?: SessionID
parentID?: SessionID
title?: string
agent?: string
@@ -756,6 +758,7 @@ export const layer: Layer.Layer<
const workspace = yield* InstanceState.workspaceID
const directory = input?.directory ?? ctx.directory
return yield* createNext({
+ id: input?.id,
parentID: input?.parentID,
directory,
path: sessionPath(ctx.worktree, directory),
@@ -815,12 +818,10 @@ export const layer: Layer.Layer<
input.isolate === "worktree" ? yield* Effect.serviceOption(Worktree.Service) : Option.none()
const worktreeInfo =
input.isolate === "worktree" && Option.isSome(worktreeOpt)
- ? yield* worktreeOpt.value
- .create({ name: `fork-${Identifier.ascending("session")}` })
- .pipe(
- Effect.catchTag("WorktreeNotGitError", () => Effect.succeed(undefined)),
- Effect.orDie,
- )
+ ? yield* worktreeOpt.value.create({ name: `fork-${Identifier.ascending("session")}` }).pipe(
+ Effect.catchTag("WorktreeNotGitError", () => Effect.succeed(undefined)),
+ Effect.orDie,
+ )
: undefined
// 附-D 阶段3: resolve the effective fork directory. Precedence: a fresh worktree (阶段4) >
diff --git a/packages/deepagent-code/src/session/tools.ts b/packages/deepagent-code/src/session/tools.ts
index b4a4d56b..a6814a09 100644
--- a/packages/deepagent-code/src/session/tools.ts
+++ b/packages/deepagent-code/src/session/tools.ts
@@ -25,6 +25,7 @@ import { EffectBridge } from "@/effect/bridge"
import { ProviderV2 } from "@deepagent-code/core/provider"
import { ModelV2 } from "@deepagent-code/core/model"
import { AgentGateway } from "@deepagent-code/core/agent-gateway"
+import { ToolSemanticFingerprint } from "@/tool/semantic-fingerprint"
const log = Log.create({ service: "session.tools" })
@@ -141,8 +142,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
type GateDirective = { kind: "block"; output: string } | { kind: "warn"; reason: string } | { kind: "pass" }
const evaluatePlanGate = (sessionID: string, isMutating: boolean): GateDirective => {
const latch = AgentGateway.DeepAgentSessionState.planLatch(sessionID)
- const planStale =
- latch?.latch === "stale" && !AgentGateway.DeepAgentPlanController.shouldEscapeToHuman(latch)
+ const planStale = latch?.latch === "stale" && !AgentGateway.DeepAgentPlanController.shouldEscapeToHuman(latch)
// Gate strength must key off THIS session's EFFECTIVE mode, not the process-global one. The global
// `snapshot().agentMode` ignores the per-request `agent_mode_override` (a downgraded subagent, or a
// session pinned below the global) — so it would over- or under-gate a turn, and disagree with
@@ -179,8 +179,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
: `${gateDecision.blockReason}. Call the \`plan\` tool first.`
return { kind: "block", output }
}
- const gateWarnReason =
- gateDecision.decision === "warn" && !lightweight ? gateDecision.blockReason : undefined
+ const gateWarnReason = gateDecision.decision === "warn" && !lightweight ? gateDecision.blockReason : undefined
// A mutating tool that actually executes is forward progress → reset the consecutive-block counter.
if (isMutating) {
AgentGateway.DeepAgentSessionState.recordMutation(sessionID)
@@ -214,7 +213,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
const command =
(item.id === "bash" || item.id === "shell") &&
typeof (args as { command?: unknown } | undefined)?.command === "string"
- ? ((args as { command: string }).command)
+ ? (args as { command: string }).command
: null
// Fail SAFE if the classifier ever throws (it is total today — pure regex/string ops — but a
// future regex/refactor could introduce a throw): treat an unclassifiable command as mutating
@@ -236,9 +235,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
// so this is a soft nudge: plan → stale but the next mutating tool still runs with a
// reminder rather than being blocked.
Effect.tapError(() =>
- Effect.sync(() =>
- AgentGateway.DeepAgentSessionState.markPlanStale(ctx.sessionID, "tool_failed"),
- ),
+ Effect.sync(() => AgentGateway.DeepAgentSessionState.markPlanStale(ctx.sessionID, "tool_failed")),
),
)
const withReminder =
@@ -270,6 +267,8 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
// M2 (S1-v3.4): carry the registry's explicit provenance onto the freshly
// built AI SDK tool so request.ts reads it instead of guessing from the name.
if (item.provenance) ToolProvenance.set(aiToolDef, item.provenance)
+ if (item.semanticFingerprint) ToolSemanticFingerprint.set(aiToolDef, item.semanticFingerprint)
+ if (item.resultFingerprint) ToolSemanticFingerprint.setResult(aiToolDef, item.resultFingerprint)
tools[item.id] = aiToolDef
}
diff --git a/packages/deepagent-code/src/tool/semantic-fingerprint.ts b/packages/deepagent-code/src/tool/semantic-fingerprint.ts
new file mode 100644
index 00000000..b1527774
--- /dev/null
+++ b/packages/deepagent-code/src/tool/semantic-fingerprint.ts
@@ -0,0 +1,26 @@
+import type { Tool } from "ai"
+
+export type Resolver = (input: unknown) => unknown
+
+const resolvers = new WeakMap
-
Desktop 1.4.2 · DeepAgent Core V4.0.4
+
Desktop 1.4.3 · DeepAgent Core V4.0.4_r8
---
@@ -91,16 +91,18 @@ For high-risk decisions, convene an **Expert Panel**. Correctness, security, per
Project IM brings people and agents into the same thread. Mention an agent to start a scoped run with project context, stream its progress, inspect its artifacts, and keep the answer attached to the conversation that requested it.
-## DeepAgent Core V4.0.4
+## DeepAgent Core V4.0.4_r8
-V4.0.4 closes production contract gaps while keeping the current turn engine stable:
+Desktop 1.4.3 ships the eighth reliability revision of the V4.0.4 contract. This release hardens the boundaries where long-running and delegated work previously risked duplicate execution, ambiguous completion, or renderer-wide failure:
-- **Single durable truth:** DocumentStore uses atomic, recoverable writes for documents, plans, learning candidates, governance state, and version conflicts.
-- **Isolated subagents:** write-capable subagents use dedicated worktrees by default and return their changes to the parent workspace through a bounded, conflict-aware path.
-- **Reliable event delivery:** the Event Bus has a transport seam, durable consumer offsets, offline catch-up, real priority ordering, and observable queue depth.
-- **Governed learning and goals:** knowledge promotion is tied to review evidence and ship-gate snapshots; event-driven goal ticks remain idempotent and respect quiet hours.
-- **Secure integrations:** MCP credentials use environment references or native OS secret storage on macOS, Linux, and Windows; capability and source checks fail closed.
-- **Publishing truth:** installation, CLI examples, release metadata, public domains, and supported-version documentation match the product that is actually shipped.
+- **Durable subagent execution:** TaskRun admission, generation, ownership, leases, settlement, and parent delivery are persisted. Exact retries reconcile to the original run, terminal state uses transactional compare-and-set, and a leased outbox makes completion delivery recoverable without re-running provider work.
+- **Two-stage structured finalization:** research and structured output are separate phases. The finalizer is a bounded single turn with thinking disabled when supported, only the `StructuredOutput` tool visible, no historical task or compaction payload, and no empty-success path.
+- **Fail-closed model and tool boundaries:** provider capability decisions no longer silently relax required tool choice, invalid tool input is rejected before execution, and provider, schema, permission, interruption, timeout, and doom-loop failures keep distinct recoverable terminal reasons.
+- **Semantic no-progress protection:** shell activity uses semantic fingerprints, while the no-progress budget compares bounded results, workspace state, and plan progress so cosmetic command changes cannot evade loop detection.
+- **Contained supervision UI:** subagent controls use valid sibling interactions, display real terminal state, and run behind a local ErrorBoundary with retry, close, persisted-mode validation, same-build quarantine, and recovery after build changes.
+- **Release-grade verification:** the affected Core, runtime, app, and desktop paths are covered by exact-retry, failure-injection, production Chromium, Electron cold-start, and source-map smoke tests.
+
+V4.0.4_r8 retains the durable documents, event delivery, governed learning, isolated worktrees, and secure credential boundaries introduced across the earlier V4.0.4 revisions.
## Installation
diff --git a/README.zh.md b/README.zh.md
index e6dde70a..4b1b3c38 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -14,7 +14,7 @@
Enterprise 版本
-
桌面版 1.4.2 · DeepAgent Core V4.0.4
+
桌面版 1.4.3 · DeepAgent Core V4.0.4_r8
---
@@ -91,16 +91,18 @@ DeepAgent 可以把独立工作拆分给数量有界、相互隔离的 Worker。
项目 IM 把团队成员和智能体放进同一条讨论。@ 某个智能体即可启动有明确作用域的运行,使用项目上下文、流式展示进度、关联执行工件,并把答案留在发起任务的对话里。
-## DeepAgent Core V4.0.4
+## DeepAgent Core V4.0.4_r8
-V4.0.4 在保持当前 turn 引擎稳定的前提下,关闭生产合同缺口:
+桌面版 1.4.3 搭载 V4.0.4 合同的第八次可靠性修订。本次发布重点加固长任务和委派任务的关键边界,避免重复执行、模糊终态或局部界面故障演变为整页崩溃:
-- **单一持久真相:** DocumentStore 通过原子、可恢复写入统一管理文档、计划、学习候选、治理状态和版本冲突。
-- **隔离子智能体:** 具备写权限的子智能体默认使用独立 worktree,并通过有界、可感知冲突的路径把改动回传到父工作区。
-- **可靠事件投递:** Event Bus 提供可替换 transport、持久 consumer offset、离线补投、真实优先级排序和可观测队列深度。
-- **受治理的学习与目标:** 知识晋升关联审阅证据与 ship-gate snapshot;事件驱动 goal tick 保持幂等并遵守 quiet hours。
-- **安全集成:** MCP 凭据使用环境变量引用或 macOS、Linux、Windows 原生 secret storage;capability 与来源检查逐层失败关闭。
-- **发布真实性:** 安装方式、CLI 示例、发布元数据、公开域名和支持版本文档与实际交付产品一致。
+- **持久化子智能体执行:** TaskRun 的准入、generation、owner、lease、结算与父会话投递均持久化。exact retry 会归并到原始运行,终态通过事务 CAS 结算,租约式 outbox 可在不重复执行 provider work 的前提下恢复完成通知。
+- **两阶段结构化终结:** 研究和结构化输出分为独立阶段。finalizer 是次数有界的单轮执行;在 provider 支持时关闭 thinking,只暴露 `StructuredOutput`,不混入历史 task 或 compaction 内容,也不存在空结果成功路径。
+- **失败关闭的模型与工具边界:** provider capability 决策不再静默放宽 required tool choice;无效工具输入在执行前被拒绝;provider、schema、permission、interruption、timeout 与 doom-loop 保留不同且可恢复的终态原因。
+- **语义级无进展保护:** shell 使用语义 fingerprint;无进展预算同时比较有界结果、工作区状态与计划进度,不能再靠改写命令描述绕过循环检测。
+- **故障隔离的监督面板:** 子智能体控件使用合法的 sibling interaction,展示真实终态,并由局部 ErrorBoundary、重试/关闭、持久 mode 校验、同 build quarantine 与 build 变化后的恢复机制保护。
+- **发布级验证:** 受影响的 Core、运行时、App 与 Desktop 路径已覆盖 exact retry、故障注入、production Chromium、Electron 冷启动和 source-map smoke。
+
+V4.0.4_r8 同时保留此前 V4.0.4 各修订建立的持久文档、可靠事件投递、知识治理、worktree 隔离与安全凭据边界。
## 安装
diff --git a/bun.lock b/bun.lock
index 63533c10..eec42051 100644
--- a/bun.lock
+++ b/bun.lock
@@ -129,7 +129,7 @@
},
"packages/core": {
"name": "@deepagent-code/core",
- "version": "1.0.0-beta",
+ "version": "4.0.4-r8",
"bin": {
"deepagent-code": "./bin/deepagent-code",
},
diff --git a/design/README.md b/design/README.md
index 384f9236..07baa4b9 100644
--- a/design/README.md
+++ b/design/README.md
@@ -1,6 +1,6 @@
# DeepAgent Code Architecture & Design
-> **Public design overview for DeepAgent Core V4.0.4 / Desktop 1.4.2.** Internal implementation details and roadmap documents live in the private `docs/` tree and are intentionally not version-controlled.
+> **Public design overview for DeepAgent Core V4.0.4_r8 / Desktop 1.4.3.** Internal implementation details and roadmap documents live in the private `docs/` tree and are intentionally not version-controlled.
DeepAgent Code is a document-centered, event-driven AI coding system. It combines a coding-agent runtime with a durable control plane that owns context, planning, learning, collaboration, safety, and human oversight.
@@ -22,7 +22,13 @@ Sessions, inputs, plans, documents, goals, events, approvals, and learning decis
A user instruction is durably admitted before execution is scheduled. A successful API response therefore means the instruction is recorded, not merely present in a process-local queue.
-DeepAgent is built **on top of** the opencode agent/runtime/session/tool/MCP stack. V4.0.4 strengthens the control plane without replacing the current turn engine, tool system, or provider layer.
+DeepAgent is built **on top of** the opencode agent/runtime/session/tool/MCP stack. V4.0.4_r8 strengthens the control plane without replacing the current turn engine, tool system, or provider layer.
+
+### Durable delegated execution
+
+A delegated task is admitted before provider work begins and is identified independently from the tool-call attempt that submitted it. Exact retries reconcile to the same TaskRun; conflicting reuse fails closed. Generation, execution owner, lease, phase, terminal state, result reference, and parent-notification delivery are durable records. Active mutations and terminal settlement require the expected generation, owner, and live lease, while notification delivery uses an attempt-fenced outbox so crash recovery cannot repeat provider work or acknowledge another claimant's delivery.
+
+Structured work uses two explicit stages. The research stage retains the child Agent's normal tools and transcript. The finalizer is a bounded, single provider turn with a narrow ephemeral tool registry, no task or compaction history, no ordinary steering, and a per-turn reasoning/tool-choice decision derived from provider capability. Completion is valid only after schema-validated structured output is persisted; provider, schema, permission, timeout, interruption, and no-progress failures remain distinct recoverable states.
### 2. One durable authority per concern
diff --git a/packages/app/README.md b/packages/app/README.md
index 1bf37cbd..43e278ff 100644
--- a/packages/app/README.md
+++ b/packages/app/README.md
@@ -2,6 +2,8 @@
SolidJS front-end shell for the DeepAgent Code desktop app (Electron/Tauri).
+Current release: Desktop 1.4.3, powered by DeepAgent Core V4.0.4_r8.
+
## Stack
- **UI:** SolidJS + Vite (Bun)
diff --git a/packages/app/src/utils/solid-dnd.tsx b/packages/app/src/utils/solid-dnd.tsx
index 6c3bb3d5..b5e279e8 100644
--- a/packages/app/src/utils/solid-dnd.tsx
+++ b/packages/app/src/utils/solid-dnd.tsx
@@ -165,4 +165,3 @@ export const FixedDragDropSensors = (props: { children?: JSXElement }): JSXEleme
createFixedPointerSensor()
return props.children as JSXElement
}
-
diff --git a/packages/core/package.json b/packages/core/package.json
index dcb07b07..ed040dcb 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "1.0.0-beta",
+ "version": "4.0.4-r8",
"name": "@deepagent-code/core",
"type": "module",
"license": "AGPL-3.0-or-later",
diff --git a/packages/deepagent-code/README.md b/packages/deepagent-code/README.md
index 4c74998f..26b6f2d7 100644
--- a/packages/deepagent-code/README.md
+++ b/packages/deepagent-code/README.md
@@ -2,6 +2,8 @@
DeepAgent Code is a document-centered AI coding agent. This package contains the CLI/server runtime used by the terminal and desktop applications.
+The current product release is Desktop 1.4.3 with DeepAgent Core V4.0.4_r8.
+
DeepAgent Code keeps the opencode runtime foundation and adds the DeepAgent control plane:
- typed-document memory for run state, durable knowledge, worklogs, decisions, diagnosis, and context snapshots.
diff --git a/packages/desktop/README.md b/packages/desktop/README.md
index 20066dd3..3c04622a 100644
--- a/packages/desktop/README.md
+++ b/packages/desktop/README.md
@@ -2,6 +2,8 @@
The DeepAgent Code Desktop app, built with Electron.
+Current release: Desktop 1.4.3, powered by DeepAgent Core V4.0.4_r8.
+
## Development
```bash
From 07e7e4057775560fe2ac057bf37a4e774d99e1a3 Mon Sep 17 00:00:00 2001
From: deepagent-ai
Date: Sun, 26 Jul 2026 00:59:22 +0800
Subject: [PATCH 20/22] feat(provider): refresh supported model lists
---
.../components/dialog-connect-provider.tsx | 20 ++---
.../components/provider-model-refresh.test.ts | 21 +++++
.../src/components/provider-model-refresh.ts | 13 +++
.../app/src/components/settings-providers.tsx | 82 +++++++++++++++----
.../src/components/settings-v2/providers.tsx | 82 +++++++++++++++----
packages/app/src/context/server-sync.tsx | 5 ++
packages/app/src/i18n/en.ts | 3 +
packages/app/src/i18n/zh.ts | 3 +
.../src/provider/discovery-cache.ts | 16 ++--
.../deepagent-code/src/provider/provider.ts | 70 ++++++++++++----
.../instance/httpapi/groups/provider.ts | 22 +++++
.../instance/httpapi/handlers/provider.ts | 82 ++++++++++++++++++-
.../server/routes/instance/httpapi/server.ts | 4 +
packages/deepagent-code/test/fake/provider.ts | 1 +
.../test/provider/discovery-cache.test.ts | 13 +++
.../test/provider/provider.test.ts | 46 +++++++++++
.../test/server/httpapi-exercise/index.ts | 8 ++
packages/sdk/js/src/gen/sdk.gen.ts | 38 +++++++++
packages/sdk/js/src/gen/types.gen.ts | 34 ++++++++
packages/sdk/js/src/v2/gen/sdk.gen.ts | 38 +++++++++
packages/sdk/js/src/v2/gen/types.gen.ts | 34 ++++++++
21 files changed, 565 insertions(+), 70 deletions(-)
create mode 100644 packages/app/src/components/provider-model-refresh.test.ts
create mode 100644 packages/app/src/components/provider-model-refresh.ts
diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx
index 80d83dc3..8af98d18 100644
--- a/packages/app/src/components/dialog-connect-provider.tsx
+++ b/packages/app/src/components/dialog-connect-provider.tsx
@@ -48,11 +48,6 @@ const providerKind = (providerID: string) => (providerID === "anthropic" ? "anth
type DiscoveredProviderModel = { id: string; name: string }
-const discoveredModelConfig = (model: DiscoveredProviderModel) => ({
- id: model.id,
- name: model.name,
-})
-
export function DialogConnectProvider(props: { provider: string }) {
const dialog = useDialog()
const serverSync = useServerSync()
@@ -486,7 +481,6 @@ export function DialogConnectProvider(props: { provider: string }) {
return options
}
-
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
@@ -577,6 +571,8 @@ export function DialogConnectProvider(props: { provider: string }) {
setFormStore("selectedModel", nextSelected.id)
const current = serverSync.data.config.provider?.[props.provider] ?? {}
+ const keepLegacySnapshot =
+ current.discovery !== true && current.npm === undefined && Object.keys(current.models ?? {}).length > 0
await serverSync.updateConfig({
provider: {
[props.provider]: {
@@ -587,10 +583,14 @@ export function DialogConnectProvider(props: { provider: string }) {
baseURL,
...(key ? { apiKey: key } : {}),
},
- models: {
- ...(current.models ?? {}),
- ...Object.fromEntries(nextModels.map((model) => [model.id, discoveredModelConfig(model)])),
- },
+ // Keep the provider's live /models endpoint authoritative after the initial import.
+ // Existing discovery-mode entries may contain intentional per-model overrides. Legacy
+ // static snapshots stay untagged because config updates merge nested maps and cannot
+ // safely clear them; the backend recognizes that old shape and ignores the snapshot
+ // whenever a live list is available.
+ ...(keepLegacySnapshot
+ ? {}
+ : { discovery: true, models: current.discovery ? (current.models ?? {}) : {} }),
},
},
disabled_providers: (serverSync.data.config.disabled_providers ?? []).filter((id) => id !== props.provider),
diff --git a/packages/app/src/components/provider-model-refresh.test.ts b/packages/app/src/components/provider-model-refresh.test.ts
new file mode 100644
index 00000000..20fb6526
--- /dev/null
+++ b/packages/app/src/components/provider-model-refresh.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, test } from "bun:test"
+import { canRefreshProviderModels } from "./provider-model-refresh"
+
+describe("provider model refresh", () => {
+ test("allows official, discovery, and legacy imported providers", () => {
+ expect(canRefreshProviderModels("openai", undefined)).toBe(true)
+ expect(canRefreshProviderModels("custom", { discovery: true })).toBe(true)
+ expect(
+ canRefreshProviderModels("mistral", {
+ options: { baseURL: "https://api.mistral.ai/v1" },
+ models: { mistral: { name: "Mistral" } },
+ }),
+ ).toBe(true)
+ expect(
+ canRefreshProviderModels("manual", {
+ npm: "@ai-sdk/openai-compatible",
+ options: { baseURL: "https://manual.example/v1" },
+ }),
+ ).toBe(false)
+ })
+})
diff --git a/packages/app/src/components/provider-model-refresh.ts b/packages/app/src/components/provider-model-refresh.ts
new file mode 100644
index 00000000..90786742
--- /dev/null
+++ b/packages/app/src/components/provider-model-refresh.ts
@@ -0,0 +1,13 @@
+import { isOfficialProvider } from "@deepagent-code/core/provider-official"
+import type { ProviderConfig } from "@deepagent-code/sdk/v2"
+
+export function canRefreshProviderModels(providerID: string, config: ProviderConfig | undefined) {
+ if (isOfficialProvider(providerID)) return true
+ if (config?.discovery === true) return true
+ return (
+ config?.npm === undefined &&
+ typeof config?.options?.baseURL === "string" &&
+ !!config.options.baseURL &&
+ Object.keys(config.models ?? {}).length > 0
+ )
+}
diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx
index 855668c1..07f5da31 100644
--- a/packages/app/src/components/settings-providers.tsx
+++ b/packages/app/src/components/settings-providers.tsx
@@ -5,6 +5,7 @@ import { Tag } from "@deepagent-code/ui/tag"
import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js"
+import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
@@ -13,6 +14,7 @@ import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
import { SettingsList } from "./settings-list"
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
+import { canRefreshProviderModels } from "./provider-model-refresh"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType["connected"]>[number]
@@ -46,6 +48,7 @@ const SettingsProvidersContent: Component = () => {
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const providers = useProviders()
+ const [refreshing, setRefreshing] = createStore>({})
const isConfigCustom = (providerID: string) => {
const provider = serverSync.data.config.provider?.[providerID]
@@ -92,6 +95,32 @@ const SettingsProvidersContent: Component = () => {
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
+ const canRefresh = (providerID: string) =>
+ canRefreshProviderModels(providerID, serverSync.data.config.provider?.[providerID])
+
+ const refreshModels = async (providerID: string, name: string) => {
+ if (refreshing[providerID]) return
+ setRefreshing(providerID, true)
+ await serverSDK.client.provider.models
+ .refresh({ providerID }, { throwOnError: true })
+ .then((result) => {
+ serverSync.refreshProviders()
+ showToast({
+ variant: "success",
+ icon: "circle-check",
+ title: language.t("provider.models.refresh.toast.title", { provider: name }),
+ description: language.t("provider.models.refresh.toast.description", {
+ count: Object.keys(result.data?.models ?? {}).length,
+ }),
+ })
+ })
+ .catch((err: unknown) => {
+ const message = err instanceof Error ? err.message : String(err)
+ showToast({ title: language.t("common.requestFailed"), description: message })
+ })
+ .finally(() => setRefreshing(providerID, false))
+ }
+
const disableProvider = async (providerID: string, name: string) => {
const before = serverSync.data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
@@ -180,25 +209,42 @@ const SettingsProvidersContent: Component = () => {
{item.name}{type(item)}
-
- {language.t("settings.providers.connected.environmentDescription")}
-
- }
- >
- {
- event.stopPropagation()
- void disconnect(item.id, item.name)
- }}
+