From 955d106465663581376da0955c0eb953432d8e6d Mon Sep 17 00:00:00 2001 From: taibai Date: Mon, 8 Jun 2026 16:21:26 +0800 Subject: [PATCH 01/64] feat(tui): empty enter scrolls viewport to bottom --- internal/cli/chat_tui.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/cli/chat_tui.go b/internal/cli/chat_tui.go index cdee4d263..929aa8e37 100644 --- a/internal/cli/chat_tui.go +++ b/internal/cli/chat_tui.go @@ -1041,6 +1041,7 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) { line := strings.TrimSpace(m.input.Value()) if line == "" { + m.viewport.GotoBottom() return m, nil } if line == "exit" || line == "quit" || line == ":q" { From d7c3c919a8093432ed1baa8a4acecd1a262a80a7 Mon Sep 17 00:00:00 2001 From: taibai Date: Mon, 8 Jun 2026 17:35:51 +0800 Subject: [PATCH 02/64] fix(tui): also scroll to bottom on empty enter during running state --- internal/cli/chat_tui.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/cli/chat_tui.go b/internal/cli/chat_tui.go index 929aa8e37..af39bf6f9 100644 --- a/internal/cli/chat_tui.go +++ b/internal/cli/chat_tui.go @@ -1017,6 +1017,7 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.state == tuiRunning { line := strings.TrimSpace(m.input.Value()) if line == "" { + m.viewport.GotoBottom() return m, nil } if m.queueEditCursor >= 0 && m.queueEditCursor < len(m.pendingInterject) { From 0914389e2e6e10a16833767fe73cdcc3323cbe78 Mon Sep 17 00:00:00 2001 From: taibai Date: Mon, 8 Jun 2026 17:44:42 +0800 Subject: [PATCH 03/64] test(tui): add regression test for empty-enter scroll-to-bottom in both idle and running state --- internal/cli/chat_tui_test.go | 48 +++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/internal/cli/chat_tui_test.go b/internal/cli/chat_tui_test.go index ca2b247dc..0050bcf1e 100644 --- a/internal/cli/chat_tui_test.go +++ b/internal/cli/chat_tui_test.go @@ -1101,6 +1101,54 @@ func TestTranscriptTailFollow(t *testing.T) { } } +// TestEmptyEnterScrollsToBottom proves that pressing Enter with an empty composer +// scrolls the viewport to the bottom in both idle and running states, so the user +// can quickly tail-follow after scrolling up to read history. +func TestEmptyEnterScrollsToBottom(t *testing.T) { + ctrl := control.New(control.Options{}) + ch := make(chan event.Event, 1) + notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"}) + adv := func(m chatTUI, msg tea.Msg) chatTUI { + n, _ := m.Update(msg) + return n.(chatTUI) + } + + // --- idle state --- + t.Run("idle", func(t *testing.T) { + cur := adv(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 8}) + for i := 0; i < 12; i++ { + cur = adv(cur, notice) + } + // Scroll up to leave the bottom. + cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp}) + if cur.viewport.AtBottom() { + t.Fatal("wheel-up should break the bottom pin") + } + // Empty enter → should snap back to bottom. + cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyEnter}) + if !cur.viewport.AtBottom() { + t.Error("empty enter while idle should scroll viewport to bottom") + } + }) + + // --- running state --- + t.Run("running", func(t *testing.T) { + cur := adv(newChatTUI(ctrl, "", ch, 80), tea.WindowSizeMsg{Width: 80, Height: 8}) + for i := 0; i < 12; i++ { + cur = adv(cur, notice) + } + cur.state = tuiRunning + cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp}) + if cur.viewport.AtBottom() { + t.Fatal("wheel-up should break the bottom pin") + } + cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyEnter}) + if !cur.viewport.AtBottom() { + t.Error("empty enter while running should scroll viewport to bottom") + } + }) +} + func TestFoldedPasteUsesPlaceholderAndExpandsOnSend(t *testing.T) { m := newTestChatTUI() pasted := "{\n \"a\": 1,\n \"b\": 2,\n \"c\": 3,\n \"d\": 4\n}" From e40a98eb5569e78d58bd26d05183cfb41db382e1 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 9 Jun 2026 03:13:13 -0700 Subject: [PATCH 04/64] fix(cli): disable codegraph in ACP session config test (#3663) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestACPFactoryLoadsSessionCwdProjectConfig builds a full controller via boot.Build with the default config, which starts the codegraph serve daemon when the binary is installed. The daemon opens /.codegraph/codegraph.db and on Windows holds the handle past Close, so t.TempDir cleanup fails with "being used by another process". The test only exercises ACP project-command loading, so turn codegraph off — matching the boot tests' convention. Co-authored-by: reasonix --- internal/cli/acp_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/cli/acp_test.go b/internal/cli/acp_test.go index 3e71cbf92..5196acae6 100644 --- a/internal/cli/acp_test.go +++ b/internal/cli/acp_test.go @@ -75,6 +75,9 @@ func TestACPFactoryLoadsSessionCwdProjectConfig(t *testing.T) { if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(` default_model = "local" +[codegraph] +enabled = false + [[providers]] name = "local" kind = "acp-test-provider" From da8b7b566163e0a7c429723ed0f576ac2c0baecb Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 9 Jun 2026 03:28:23 -0700 Subject: [PATCH 05/64] feat(desktop): build a Linux .deb package on release (#3634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop release dropped Debian/Ubuntu packaging in the v2 rewrite — Linux only shipped a bare-binary tar.gz (discussion #3627). Add a .deb built with goreleaser/nfpm in desktop-build.sh's linux branch. - desktop/build/linux/nfpm.yaml + reasonix.desktop describe the package: binary to /usr/bin, .desktop entry, icon to /usr/share/pixmaps, and libgtk-3-0 + libwebkit2gtk-4.1-0 runtime deps apt resolves on install. - The .deb is a human-download artifact only (like the macOS .dmg); the Linux updater channel stays the tar.gz, so cmd/sign's manifest skips .deb files. A test locks the linux-amd64 key to the tarball. - release-desktop.yml installs nfpm (pinned v2.46.3) on the Linux runner. The existing sign / upload / release / R2-mirror steps already glob dist/*, so the .deb is signed, attached to the GitHub release, and mirrored with no further workflow changes. Co-authored-by: reasonix --- .github/workflows/release-desktop.yml | 6 ++++ desktop/.gitignore | 8 ++++++ desktop/build/linux/nfpm.yaml | 41 +++++++++++++++++++++++++++ desktop/build/linux/reasonix.desktop | 9 ++++++ desktop/cmd/sign/main.go | 5 ++++ desktop/cmd/sign/main_test.go | 11 +++++++ scripts/desktop-build.sh | 10 ++++++- 7 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 desktop/build/linux/nfpm.yaml create mode 100644 desktop/build/linux/reasonix.desktop diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 26c0ca54a..214994966 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -92,6 +92,12 @@ jobs: sudo apt-get update sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev + # Linux: nfpm builds the .deb in desktop-build.sh's linux branch. go install + # drops it in ~/go/bin, already on PATH (same place the wails CLI lands below). + - name: Install nfpm + if: runner.os == 'Linux' + run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.46.3 + # Windows: NSIS provides makensis for `wails build -nsis`. - name: Install NSIS if: runner.os == 'Windows' diff --git a/desktop/.gitignore b/desktop/.gitignore index 3e30c7aac..d4f6c4e5f 100644 --- a/desktop/.gitignore +++ b/desktop/.gitignore @@ -19,6 +19,14 @@ /build/darwin/* !/build/darwin/entitlements.plist +# Commit the hand-written Linux .deb packaging inputs (nfpm config + .desktop entry). +# `wails build` does not generate these, so they must live in git. The .deb is built +# by scripts/desktop-build.sh's linux branch via goreleaser/nfpm. Parent un-ignored first. +!/build/linux +/build/linux/* +!/build/linux/nfpm.yaml +!/build/linux/reasonix.desktop + # Go test binaries *.test diff --git a/desktop/build/linux/nfpm.yaml b/desktop/build/linux/nfpm.yaml new file mode 100644 index 000000000..85408acfd --- /dev/null +++ b/desktop/build/linux/nfpm.yaml @@ -0,0 +1,41 @@ +# nfpm config for the Linux .deb package. scripts/desktop-build.sh's linux branch +# runs `nfpm package` after `wails build`, from the desktop/ dir (so the src paths +# below are relative to desktop/). The .deb is a human-download artifact only — like +# the macOS .dmg — so the Linux updater channel stays the .tar.gz and cmd/sign's +# manifest skips .deb files. +# +# $DEB_VERSION and $DEB_ARCH are exported by desktop-build.sh. DEB_VERSION is the tag +# minus a leading "v" and any prerelease suffix (a strict X.Y.Z that dpkg accepts); +# DEB_ARCH is the Go arch name, which already matches Debian's (amd64, arm64). +name: "reasonix-desktop" +arch: "${DEB_ARCH}" +platform: "linux" +version: "${DEB_VERSION}" +section: "utils" +priority: "optional" +maintainer: "esengine " +description: | + Reasonix desktop — a Wails shell around the Go kernel. +vendor: "Reasonix Contributors" +homepage: "https://github.com/esengine/DeepSeek-Reasonix" +license: "MIT" +# Runtime libraries the WebKitGTK 4.1 build links against (-tags webkit2_41); apt +# pulls them in on install. These package names ship from Ubuntu 22.04 / Debian 12 on. +depends: + - libgtk-3-0 + - libwebkit2gtk-4.1-0 +contents: + - src: ./build/bin/reasonix-desktop + dst: /usr/bin/reasonix-desktop + file_info: + mode: 0755 + - src: ./build/linux/reasonix.desktop + dst: /usr/share/applications/reasonix.desktop + file_info: + mode: 0644 + # appicon.png is 1024x1024 — not a standard hicolor size dir, so install it under + # the size-agnostic pixmaps path that the .desktop's `Icon=reasonix-desktop` finds. + - src: ./build/appicon.png + dst: /usr/share/pixmaps/reasonix-desktop.png + file_info: + mode: 0644 diff --git a/desktop/build/linux/reasonix.desktop b/desktop/build/linux/reasonix.desktop new file mode 100644 index 000000000..806276a08 --- /dev/null +++ b/desktop/build/linux/reasonix.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Reasonix +Comment=Reasonix desktop — a Wails shell around the Go kernel +Exec=reasonix-desktop +Icon=reasonix-desktop +Categories=Development;Utility; +Terminal=false +StartupWMClass=reasonix-desktop diff --git a/desktop/cmd/sign/main.go b/desktop/cmd/sign/main.go index 8aa205586..751072536 100644 --- a/desktop/cmd/sign/main.go +++ b/desktop/cmd/sign/main.go @@ -207,6 +207,11 @@ func genManifest(dir, version, tag string) error { // matchPlatform returns the platform key embedded in a file name, or "" if none. func matchPlatform(name string) string { + // The .deb is a human-download package (like the macOS .dmg); the Linux updater + // channel is the .tar.gz. Skip it so it doesn't shadow the tarball's linux-amd64 key. + if strings.HasSuffix(name, ".deb") { + return "" + } if strings.Contains(name, "windows-amd64") && !strings.HasSuffix(name, "-installer.exe") { return "" } diff --git a/desktop/cmd/sign/main_test.go b/desktop/cmd/sign/main_test.go index a5403259b..f5d903144 100644 --- a/desktop/cmd/sign/main_test.go +++ b/desktop/cmd/sign/main_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "aead.dev/minisign" @@ -57,6 +58,7 @@ func TestGenManifest(t *testing.T) { "Reasonix-windows-amd64-installer.exe", "Reasonix-windows-amd64.zip", // portable download, not the updater channel "Reasonix-linux-amd64.tar.gz", + "Reasonix-linux-amd64.deb", // human download, not the updater channel "Reasonix-linux-amd64.tar.gz.minisig", // must be skipped "README.txt", // unmatched, must be skipped } @@ -98,4 +100,13 @@ func TestGenManifest(t *testing.T) { if win.SHA256 == "" || win.Size == 0 { t.Fatalf("windows asset missing digest/size: %+v", win) } + // The Linux updater channel must stay the .tar.gz; the co-located .deb is a + // human download and must not shadow the linux-amd64 key. + lin, ok := m.Platforms["linux-amd64"] + if !ok { + t.Fatal("linux-amd64 missing") + } + if !strings.HasSuffix(lin.URL, "/Reasonix-linux-amd64.tar.gz") { + t.Fatalf("linux-amd64 url = %q, want the .tar.gz, not the .deb", lin.URL) + } } diff --git a/scripts/desktop-build.sh b/scripts/desktop-build.sh index 9d8ba7eda..071cdb53f 100755 --- a/scripts/desktop-build.sh +++ b/scripts/desktop-build.sh @@ -9,7 +9,8 @@ # Reasonix-darwin-universal.dmg (drag-to-install; human download) # Windows: Reasonix-windows--installer.exe (NSIS per-user installer; updater channel) # Reasonix-windows-.zip (portable human download) -# Linux: Reasonix-linux-.tar.gz (bare binary) +# Linux: Reasonix-linux-.tar.gz (bare binary; updater channel) +# Reasonix-linux-.deb (Debian/Ubuntu package; human download) # # Usage: scripts/desktop-build.sh [channel] # e.g. scripts/desktop-build.sh darwin/arm64 v1.1.0 @@ -138,6 +139,13 @@ windows) ;; linux) tar -czf "$ROOT/dist/${APPNAME}-linux-${arch}.tar.gz" -C build/bin "$BINNAME" + # Also build a .deb for Debian/Ubuntu users (goreleaser/nfpm; see + # desktop/build/linux/nfpm.yaml). Human-download only: the Linux updater channel + # stays the tarball and cmd/sign's manifest skips .deb files. nfpm reads + # $DEB_VERSION/$DEB_ARCH — dpkg wants a strict numeric version, so reuse numver. + DEB_VERSION="$numver" DEB_ARCH="$arch" \ + nfpm package --config build/linux/nfpm.yaml --packager deb \ + --target "$ROOT/dist/${APPNAME}-linux-${arch}.deb" ;; *) echo "unsupported os: $os" >&2 From 9dd3bb3517e5a676e474e16a25c144b21dba7c1b Mon Sep 17 00:00:00 2001 From: paradoxSCH <64133628+paradoxSCH@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:51:08 +0800 Subject: [PATCH 06/64] fix(memory): handle forget failures in desktop (#3662) --- .../frontend/src/components/MemoryPanel.tsx | 27 ++++++++++++++++ desktop/frontend/src/styles.css | 11 +++++++ internal/memory/store.go | 31 ++++++++++++++++++- internal/memory/store_test.go | 20 ++++++++++++ 4 files changed, 88 insertions(+), 1 deletion(-) diff --git a/desktop/frontend/src/components/MemoryPanel.tsx b/desktop/frontend/src/components/MemoryPanel.tsx index 3a4afe5e8..d72cbbf87 100644 --- a/desktop/frontend/src/components/MemoryPanel.tsx +++ b/desktop/frontend/src/components/MemoryPanel.tsx @@ -80,6 +80,11 @@ function memoryDocPreview(body: string): string { return lines.length > 6 ? `${preview}\n...` : preview; } +function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err || "Unknown error"); +} + // MemoryPanel is the desktop memory manager: a right-side drawer over the loaded // REASONIX.md hierarchy and saved auto-memories. Unlike Claude Code's /memory // (which shells out to $EDITOR) it edits docs in place, and unlike Codex (no UI @@ -111,6 +116,7 @@ export function MemoryPanel({ const [typeFilter, setTypeFilter] = useState("all"); const [expanded, setExpanded] = useState(null); const [confirmForget, setConfirmForget] = useState(null); + const [error, setError] = useState(null); const factRefs = useRef>({}); // Filter input — a single substring search across docs and facts. The @@ -198,10 +204,13 @@ export function MemoryPanel({ const forgetFact = async (name: string) => { if (busy) return; setBusy(true); + setError(null); try { await onForget(name); if (expanded === name) setExpanded(null); setConfirmForget(null); + } catch (err) { + setError(errorMessage(err)); } finally { setBusy(false); } @@ -223,9 +232,12 @@ export function MemoryPanel({ const trimmed = note.trim(); if (!trimmed || busy) return; setBusy(true); + setError(null); try { await onRemember(activeScope, trimmed); setNote(""); + } catch (err) { + setError(errorMessage(err)); } finally { setBusy(false); } @@ -239,9 +251,12 @@ export function MemoryPanel({ const saveEdit = async () => { if (editingPath === null || busy) return; setBusy(true); + setError(null); try { await onSaveDoc(editingPath, draft); setEditingPath(null); + } catch (err) { + setError(errorMessage(err)); } finally { setBusy(false); } @@ -308,6 +323,7 @@ export function MemoryPanel({ ))} + {error &&
{error}
} {facts.length === 0 ? (
{t("memory.noFacts")}
) : filteredFacts.length === 0 ? ( @@ -582,6 +598,7 @@ export function MemorySettingsPage() { const [expanded, setExpanded] = useState(null); const [expandedDoc, setExpandedDoc] = useState(null); const [confirmForget, setConfirmForget] = useState(null); + const [error, setError] = useState(null); const [tab, setTab] = useState<"memories" | "docs">("memories"); const [showAdd, setShowAdd] = useState(false); const factRefs = useRef>({}); @@ -662,11 +679,14 @@ export function MemorySettingsPage() { const forgetFact = useCallback(async (name: string) => { if (busy) return; setBusy(true); + setError(null); try { await app.Forget(name); await reload(); if (expanded === name) setExpanded(null); setConfirmForget(null); + } catch (err) { + setError(errorMessage(err)); } finally { setBusy(false); } @@ -680,11 +700,14 @@ export function MemorySettingsPage() { const trimmed = note.trim(); if (!trimmed || busy) return; setBusy(true); + setError(null); try { await app.Remember(activeScope, trimmed); await reload(); setNote(""); setShowAdd(false); + } catch (err) { + setError(errorMessage(err)); } finally { setBusy(false); } @@ -698,10 +721,13 @@ export function MemorySettingsPage() { const saveEdit = useCallback(async () => { if (editingPath === null || busy) return; setBusy(true); + setError(null); try { await app.SaveDoc(editingPath, draft); await reload(); setEditingPath(null); + } catch (err) { + setError(errorMessage(err)); } finally { setBusy(false); } @@ -827,6 +853,7 @@ export function MemorySettingsPage() { ))} + {error &&
{error}
} {facts.length === 0 ? (
{t("memory.noFacts")}
) : filteredFacts.length === 0 ? ( diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index 714665af9..9ff15e4d4 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -5065,6 +5065,17 @@ a[href] { color: var(--danger, #d66); font-size: 11.5px; } +.mem-error { + margin-top: 8px; + padding: 7px 9px; + border: 1px solid color-mix(in srgb, var(--danger, #d66) 34%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--danger, #d66) 10%, transparent); + color: var(--danger, #d66); + font-size: 11.5px; + line-height: 1.4; + overflow-wrap: anywhere; +} .mem-fact--hl { background: var(--accent-soft, rgba(120, 170, 255, 0.12)); border-color: color-mix(in srgb, var(--accent) 42%, transparent); diff --git a/internal/memory/store.go b/internal/memory/store.go index 8eb773daa..c779d92ea 100644 --- a/internal/memory/store.go +++ b/internal/memory/store.go @@ -130,12 +130,41 @@ func (s Store) Delete(name string) error { if name == "" { return fmt.Errorf("memory needs a name") } - if err := os.Remove(filepath.Join(s.Dir, name+".md")); err != nil && !os.IsNotExist(err) { + if err := removeMemoryFile(filepath.Join(s.Dir, name+".md")); err != nil { return err } return s.flushIndex(s.indexLinesExcept(name)) } +func removeMemoryFile(path string) error { + err := os.Remove(path) + if err == nil || os.IsNotExist(err) { + return nil + } + if !os.IsPermission(err) { + return err + } + repairOwnerWrite(path, false) + repairOwnerWrite(filepath.Dir(path), true) + err = os.Remove(path) + if err == nil || os.IsNotExist(err) { + return nil + } + return err +} + +func repairOwnerWrite(path string, dir bool) { + info, err := os.Stat(path) + if err != nil { + return + } + need := os.FileMode(0o600) + if dir { + need = 0o700 + } + _ = os.Chmod(path, info.Mode().Perm()|need) +} + // render serializes a memory to frontmatter + body. The frontmatter mirrors the // auto-memory shape (name / description / metadata.type) so the files are // interchangeable with that ecosystem and re-readable by loadMemory. diff --git a/internal/memory/store_test.go b/internal/memory/store_test.go index 1aa4b33ef..13e3a8940 100644 --- a/internal/memory/store_test.go +++ b/internal/memory/store_test.go @@ -146,6 +146,26 @@ func TestStoreDeleteMissingIsNoError(t *testing.T) { } } +func TestStoreDeleteRepairsReadOnlyMemoryFile(t *testing.T) { + s := Store{Dir: t.TempDir()} + if _, err := s.Save(Memory{Name: "locked", Description: "d", Type: TypeProject, Body: "b"}); err != nil { + t.Fatal(err) + } + path := filepath.Join(s.Dir, "locked.md") + if err := os.Chmod(path, 0o400); err != nil { + t.Fatal(err) + } + if err := s.Delete("locked"); err != nil { + t.Fatalf("delete read-only memory: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("locked.md should be gone, stat err = %v", err) + } + if strings.Contains(s.Index(), "locked.md") { + t.Fatalf("deleted read-only entry still in index:\n%s", s.Index()) + } +} + // TestNormalizeType maps unknown types to project and keeps known ones. func TestNormalizeType(t *testing.T) { if got := NormalizeType("feedback"); got != TypeFeedback { From 30477177de392be35b580cf3fc60d7a3c955ad48 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 9 Jun 2026 03:58:26 -0700 Subject: [PATCH 07/64] fix(rewind): fail loudly past a compacted boundary (#3598) + suffix-scan CanCode (#3438) (#3672) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(control): fail conversation rewind loudly past a compacted boundary A turn's conversation-rewind boundary is the message-log index at turn start. Compaction shrinks the log without rewriting boundaries, so for a turn compacted away the boundary now exceeds len(Messages). Rewind tested `boundary <= len` and, when false, skipped the truncation but still emitted a "rewound conversation" success — code restored, conversation silently not. Return a clear failure instead, and only report success when the log was actually truncated. Closes #3598 * fix(desktop): mark a turn code-rewindable when a later turn changed files RestoreCode(turn) reverts every file touched in that turn or any later one, so a turn that changed no files of its own can still rewind code when a later turn did. CanCode only checked the turn's own paths, disabling the code/both rewind buttons for such turns. Propagate CanCode backwards over the oldest-first checkpoint list so the UI matches the backend's capability. Refs #3438 --------- Co-authored-by: reasonix --- desktop/app.go | 10 +++ desktop/checkpoints_cancode_test.go | 71 +++++++++++++++++++ internal/control/controller.go | 28 ++++---- internal/control/rewind_e2e_test.go | 101 ++++++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 12 deletions(-) create mode 100644 desktop/checkpoints_cancode_test.go create mode 100644 internal/control/rewind_e2e_test.go diff --git a/desktop/app.go b/desktop/app.go index 74d8ee4f4..702069446 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -741,6 +741,16 @@ func (a *App) CheckpointsForTab(tabID string) []CheckpointMeta { CanConversation: ctrl.CheckpointHasBoundary(m.Turn), }) } + // RestoreCode(turn) reverts every file touched in this turn or any later one, so + // a turn can rewind code even when it changed no files itself — as long as a + // later turn did. Propagate CanCode backwards over the oldest-first list. + hasCodeAfter := false + for i := len(out) - 1; i >= 0; i-- { + if len(out[i].Files) > 0 { + hasCodeAfter = true + } + out[i].CanCode = hasCodeAfter + } return out } diff --git a/desktop/checkpoints_cancode_test.go b/desktop/checkpoints_cancode_test.go new file mode 100644 index 000000000..f9e035622 --- /dev/null +++ b/desktop/checkpoints_cancode_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "reasonix/internal/agent" + "reasonix/internal/checkpoint" + "reasonix/internal/control" + "reasonix/internal/event" +) + +func seedCheckpoint(t *testing.T, ckptDir string, c checkpoint.Checkpoint) { + t.Helper() + b, err := json.Marshal(c) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(ckptDir, "turn-"+strconv.Itoa(c.Turn)+".json"), b, 0o644); err != nil { + t.Fatal(err) + } +} + +// TestCheckpointsCanCodePropagatesToEarlierTurns covers #3438: RestoreCode(turn) +// reverts files touched in that turn or any later one, so a turn with no file +// changes of its own can still rewind code when a later turn changed files. The +// desktop CanCode flag must reflect that suffix capability, not just the turn's +// own paths. +func TestCheckpointsCanCodePropagatesToEarlierTurns(t *testing.T) { + dir := t.TempDir() + sessionPath := filepath.Join(dir, "s.jsonl") + ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt" + if err := os.MkdirAll(ckptDir, 0o755); err != nil { + t.Fatal(err) + } + content := "old" + now := time.Now() + seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 0, Time: now, Prompt: "ask only", MsgIndex: 0}) + seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "edit a file", MsgIndex: 2, + Files: []checkpoint.FileSnap{{Path: "a.txt", Content: &content}}}) + seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 2, Time: now, Prompt: "ask again", MsgIndex: 4}) + + ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard) + ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"}) + ctrl.SetSessionPath(sessionPath) + + app := &App{} + app.setTestCtrl(ctrl, "test") + + metas := app.CheckpointsForTab("test") + if len(metas) != 3 { + t.Fatalf("checkpoints = %d, want 3", len(metas)) + } + got := map[int]bool{} + for _, m := range metas { + got[m.Turn] = m.CanCode + } + if !got[0] { + t.Error("turn 0 (no files of its own) should allow code rewind — turn 1 changed files") + } + if !got[1] { + t.Error("turn 1 changed files, should allow code rewind") + } + if got[2] { + t.Error("turn 2 is after the last file-bearing turn, should NOT allow code rewind") + } +} diff --git a/internal/control/controller.go b/internal/control/controller.go index 6e57edbe3..b11dbcb1b 100644 --- a/internal/control/controller.go +++ b/internal/control/controller.go @@ -1050,20 +1050,24 @@ func (c *Controller) Rewind(turn int, scope RewindScope) error { return c.rewindFail(fmt.Errorf("conversation rewind unavailable for turn %d (resumed session)", turn)) } s := c.executor.Session() - if boundary <= len(s.Messages) { - s.Messages = s.Messages[:boundary] - c.mu.Lock() - c.cpTurn = turn // renumber future turns from here; later turns are gone - for k := range c.cpBound { - if k >= turn { - delete(c.cpBound, k) - } - } - c.mu.Unlock() - if err := c.Snapshot(); err != nil { - slog.Warn("controller: snapshot after rewind", "err", err) + // boundary is the message-log index at turn start; compaction shrinks the + // log without rewriting boundaries, so a stale boundary past the end means + // the turn was compacted away — fail loudly instead of skipping silently. + if boundary > len(s.Messages) { + return c.rewindFail(fmt.Errorf("conversation rewind unavailable for turn %d: the conversation was compacted past this point", turn)) + } + s.Messages = s.Messages[:boundary] + c.mu.Lock() + c.cpTurn = turn // renumber future turns from here; later turns are gone + for k := range c.cpBound { + if k >= turn { + delete(c.cpBound, k) } } + c.mu.Unlock() + if err := c.Snapshot(); err != nil { + slog.Warn("controller: snapshot after rewind", "err", err) + } c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf("rewound conversation to turn %d", turn)}) } diff --git a/internal/control/rewind_e2e_test.go b/internal/control/rewind_e2e_test.go new file mode 100644 index 000000000..3ce498dc7 --- /dev/null +++ b/internal/control/rewind_e2e_test.go @@ -0,0 +1,101 @@ +package control + +import ( + "context" + "strings" + "testing" + + "reasonix/internal/agent" + "reasonix/internal/event" + "reasonix/internal/provider" + "reasonix/internal/tool" +) + +func runTwoTurns(t *testing.T) (*Controller, *agent.Agent, *[]event.Event) { + t.Helper() + dir := t.TempDir() + prov := &scriptedTurns{turns: [][]provider.Chunk{ + textTurn("first answer"), + textTurn("second answer"), + }} + ag := agent.New(prov, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard) + var events []event.Event + c := New(Options{ + Runner: ag, + Executor: ag, + SessionDir: dir, + Label: "test", + Sink: event.FuncSink(func(e event.Event) { events = append(events, e) }), + }) + c.SetSessionPath(agent.NewSessionPath(dir, "test")) + if err := c.runTurnWithRaw(context.Background(), "first prompt", "first prompt"); err != nil { + t.Fatalf("turn 1: %v", err) + } + if err := c.runTurnWithRaw(context.Background(), "second prompt", "second prompt"); err != nil { + t.Fatalf("turn 2: %v", err) + } + return c, ag, &events +} + +// TestRewindConversationFailsLoudlyAfterCompaction reproduces #3598: once +// compaction shrinks the message log below a turn's recorded boundary, a +// conversation/both rewind to that turn skipped the truncation but still emitted +// a success notice — code rolled back, conversation silently did not. +func TestRewindConversationFailsLoudlyAfterCompaction(t *testing.T) { + c, ag, events := runTwoTurns(t) + + c.mu.Lock() + lastTurn := c.cpTurn - 1 + boundary := c.cpBound[lastTurn] + c.mu.Unlock() + if boundary <= 1 { + t.Fatalf("expected the latest turn's boundary above 1, got cpBound=%v", c.cpBound) + } + + // Auto-compaction replaces the prefix with a summary, shrinking the log below + // the recorded boundary; compaction does not rewrite checkpoint boundaries. + sess := ag.Session() + sess.Messages = []provider.Message{{Role: provider.RoleUser, Content: "summary"}} + + *events = nil + err := c.Rewind(lastTurn, RewindBoth) + if err == nil || !strings.Contains(err.Error(), "compacted") { + t.Fatalf("Rewind after compaction error = %v, want a 'compacted past' failure", err) + } + for _, e := range *events { + if e.Kind == event.Notice && strings.Contains(e.Text, "rewound conversation") { + t.Fatalf("emitted a false conversation-rewind success after skipping truncation: %q", e.Text) + } + } + if got := len(ag.Session().Messages); got != 1 { + t.Fatalf("session messages = %d, want the compacted log left intact at 1", got) + } +} + +// TestRewindConversationSucceedsWithLiveBoundary is the companion happy path: a +// boundary still within the log truncates the conversation and reports success. +func TestRewindConversationSucceedsWithLiveBoundary(t *testing.T) { + c, ag, events := runTwoTurns(t) + + c.mu.Lock() + lastTurn := c.cpTurn - 1 + boundary := c.cpBound[lastTurn] + c.mu.Unlock() + + *events = nil + if err := c.Rewind(lastTurn, RewindConversation); err != nil { + t.Fatalf("Rewind with a live boundary: %v", err) + } + if got := len(ag.Session().Messages); got != boundary { + t.Fatalf("session truncated to %d messages, want boundary %d", got, boundary) + } + ok := false + for _, e := range *events { + if e.Kind == event.Notice && strings.Contains(e.Text, "rewound conversation") { + ok = true + } + } + if !ok { + t.Fatal("expected a conversation-rewind success notice") + } +} From efa50ee78da0d70b6fa62331b42386cad41b0e58 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 9 Jun 2026 04:09:28 -0700 Subject: [PATCH 08/64] chore(ci): add Dependabot config and CodeQL code scanning (#3676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(ci): add Dependabot config and CodeQL code scanning Enable Dependabot version updates (gomod for the root and desktop modules; npm for desktop/frontend, site, and npm/reasonix; github-actions) with grouped minor/patch PRs to keep noise down, and add a CodeQL workflow (go, javascript-typescript, actions; build-mode none so the multi-module repo needs no build step) on push/PR to main-v2 plus a weekly scan. * chore(ci): use codeql-action v4 (v3 deprecates Dec 2026) * fix(ci): CodeQL Go requires a build — use autobuild, not none --------- Co-authored-by: reasonix --- .github/dependabot.yml | 55 ++++++++++++++++++++++++++++++++++++ .github/workflows/codeql.yml | 40 ++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..53b33bcd5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,55 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + go: + patterns: ["*"] + update-types: ["minor", "patch"] + + - package-ecosystem: "gomod" + directory: "/desktop" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + go: + patterns: ["*"] + update-types: ["minor", "patch"] + + - package-ecosystem: "npm" + directory: "/desktop/frontend" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + npm: + patterns: ["*"] + update-types: ["minor", "patch"] + + - package-ecosystem: "npm" + directory: "/site" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + npm: + patterns: ["*"] + update-types: ["minor", "patch"] + + - package-ecosystem: "npm" + directory: "/npm/reasonix" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + actions: + patterns: ["*"] diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..0ae666d51 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,40 @@ +name: CodeQL + +on: + push: + branches: ["main-v2"] + pull_request: + branches: ["main-v2"] + schedule: + - cron: "27 3 * * 1" + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + security-events: write + packages: read + actions: read + contents: read + strategy: + fail-fast: false + matrix: + include: + - language: go + build-mode: autobuild + - language: javascript-typescript + build-mode: none + - language: actions + build-mode: none + steps: + - uses: actions/checkout@v4 + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}" From 74c148323cb369220afa1d2e93ac0ee580f9c3cf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:24:14 -0700 Subject: [PATCH 09/64] chore(deps): bump the go group in /desktop with 5 updates (#3680) Bumps the go group in /desktop with 5 updates: | Package | From | To | | --- | --- | --- | | [fyne.io/systray](https://github.com/fyne-io/systray) | `1.12.1` | `1.12.2` | | [github.com/wailsapp/wails/v2](https://github.com/wailsapp/wails) | `2.11.0` | `2.12.0` | | [golang.org/x/mod](https://github.com/golang/mod) | `0.36.0` | `0.37.0` | | [golang.org/x/sys](https://github.com/golang/sys) | `0.45.0` | `0.46.0` | | [golang.org/x/text](https://github.com/golang/text) | `0.37.0` | `0.38.0` | Updates `fyne.io/systray` from 1.12.1 to 1.12.2 - [Changelog](https://github.com/fyne-io/systray/blob/master/CHANGELOG.md) - [Commits](https://github.com/fyne-io/systray/compare/v1.12.1...v1.12.2) Updates `github.com/wailsapp/wails/v2` from 2.11.0 to 2.12.0 - [Release notes](https://github.com/wailsapp/wails/releases) - [Commits](https://github.com/wailsapp/wails/compare/v2.11.0...v2.12.0) Updates `golang.org/x/mod` from 0.36.0 to 0.37.0 - [Commits](https://github.com/golang/mod/compare/v0.36.0...v0.37.0) Updates `golang.org/x/sys` from 0.45.0 to 0.46.0 - [Commits](https://github.com/golang/sys/compare/v0.45.0...v0.46.0) Updates `golang.org/x/text` from 0.37.0 to 0.38.0 - [Release notes](https://github.com/golang/text/releases) - [Commits](https://github.com/golang/text/compare/v0.37.0...v0.38.0) --- updated-dependencies: - dependency-name: fyne.io/systray dependency-version: 1.12.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: go - dependency-name: github.com/wailsapp/wails/v2 dependency-version: 2.12.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go - dependency-name: golang.org/x/mod dependency-version: 0.37.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go - dependency-name: golang.org/x/sys dependency-version: 0.46.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go - dependency-name: golang.org/x/text dependency-version: 0.38.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- desktop/go.mod | 11 ++++++----- desktop/go.sum | 22 ++++++++++++---------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/desktop/go.mod b/desktop/go.mod index 12bb60d21..f44e08584 100644 --- a/desktop/go.mod +++ b/desktop/go.mod @@ -13,16 +13,17 @@ require reasonix v0.0.0 require ( aead.dev/minisign v0.3.0 - fyne.io/systray v1.12.1 + fyne.io/systray v1.12.2 github.com/godbus/dbus/v5 v5.2.2 github.com/minio/selfupdate v0.6.0 - github.com/wailsapp/wails/v2 v2.11.0 - golang.org/x/mod v0.36.0 - golang.org/x/sys v0.45.0 - golang.org/x/text v0.37.0 + github.com/wailsapp/wails/v2 v2.12.0 + golang.org/x/mod v0.37.0 + golang.org/x/sys v0.46.0 + golang.org/x/text v0.38.0 ) require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect diff --git a/desktop/go.sum b/desktop/go.sum index 8361f7b30..193c4e07f 100644 --- a/desktop/go.sum +++ b/desktop/go.sum @@ -1,8 +1,10 @@ aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ= aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA= aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y= -fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ= -fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= +fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= @@ -74,8 +76,8 @@ github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+ github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= -github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ= -github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= +github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= +github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -83,8 +85,8 @@ golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWP golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -98,14 +100,14 @@ golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 8e605194075d1a30b472d0475eb06d049596370b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:24:20 -0700 Subject: [PATCH 10/64] chore(deps-dev): bump @vitejs/plugin-react in /desktop/frontend (#3687) Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 4.7.0 to 5.2.0. - [Release notes](https://github.com/vitejs/vite-plugin-react/releases) - [Changelog](https://github.com/vitejs/vite-plugin-react/blob/plugin-react@5.2.0/packages/plugin-react/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.2.0/packages/plugin-react) --- updated-dependencies: - dependency-name: "@vitejs/plugin-react" dependency-version: 5.2.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- desktop/frontend/package-lock.json | 64 +++++++++++++++--------------- desktop/frontend/package.json | 2 +- desktop/frontend/pnpm-lock.yaml | 62 ++++++++++++++--------------- 3 files changed, 64 insertions(+), 64 deletions(-) diff --git a/desktop/frontend/package-lock.json b/desktop/frontend/package-lock.json index d478c6c51..ef8e307cc 100644 --- a/desktop/frontend/package-lock.json +++ b/desktop/frontend/package-lock.json @@ -23,7 +23,7 @@ "@types/node": "^25.9.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-react": "^5.2.0", "terser": "^5.48.0", "tsx": "^4.22.4", "typescript": "^5.6.3", @@ -816,9 +816,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", "dev": true, "license": "MIT" }, @@ -1347,24 +1347,24 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.0", + "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", + "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "react-refresh": "^0.18.0" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/acorn": { @@ -2148,23 +2148,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/mdast-util-gfm-autolink-literal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", - "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^4.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "micromark-util-character": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/mdast-util-gfm-footnote": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", @@ -2230,6 +2213,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-gfm/node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.0.tgz", + "integrity": "sha512-FyzMsduZZHSc3i0Px3PQcBT4WJY/X/RCtEJKuybiC6sjPqLv7h1yqAkmILZtuxMSsUyaLUWNp71+vQH2zqp5cg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-math": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", @@ -3160,9 +3160,9 @@ } }, "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", "dev": true, "license": "MIT", "engines": { diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index 0edb49adb..533614e18 100644 --- a/desktop/frontend/package.json +++ b/desktop/frontend/package.json @@ -30,7 +30,7 @@ "@types/node": "^25.9.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-react": "^5.2.0", "terser": "^5.48.0", "tsx": "^4.22.4", "typescript": "^5.6.3", diff --git a/desktop/frontend/pnpm-lock.yaml b/desktop/frontend/pnpm-lock.yaml index 66662beca..f6b116da4 100644 --- a/desktop/frontend/pnpm-lock.yaml +++ b/desktop/frontend/pnpm-lock.yaml @@ -52,8 +52,8 @@ importers: specifier: ^18.3.1 version: 18.3.7(@types/react@18.3.29) '@vitejs/plugin-react': - specifier: ^4.3.4 - version: 4.7.0(vite@6.4.2(@types/node@25.9.1)(terser@5.48.0)(tsx@4.22.4)) + specifier: ^5.2.0 + version: 5.2.0(vite@6.4.2(@types/node@25.9.1)(terser@5.48.0)(tsx@4.22.4)) terser: specifier: ^5.48.0 version: 5.48.0 @@ -483,8 +483,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@rolldown/pluginutils@1.0.0-beta.27': - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} '@rollup/rollup-android-arm-eabi@4.60.4': resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} @@ -676,11 +676,11 @@ packages: '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} - '@vitejs/plugin-react@4.7.0': - resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} - engines: {node: ^14.18.0 || >=16.0.0} + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} @@ -690,8 +690,8 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} - baseline-browser-mapping@2.10.32: - resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==} + baseline-browser-mapping@2.10.34: + resolution: {integrity: sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==} engines: {node: '>=6.0.0'} hasBin: true @@ -703,8 +703,8 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - caniuse-lite@1.0.30001793: - resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + caniuse-lite@1.0.30001797: + resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -756,8 +756,8 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - electron-to-chromium@1.5.364: - resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} + electron-to-chromium@1.5.370: + resolution: {integrity: sha512-D5tSHJReAb/Kf3Hu9F/GO4lJuSWzEWHwvQ/kKSUP7pimNgvxkSKj+gUQhHpKKACwrin7rS3byU7IxreF56rl5g==} entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} @@ -1043,8 +1043,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - node-releases@2.0.46: - resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + node-releases@2.0.47: + resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} engines: {node: '>=18'} parse-entities@4.0.2: @@ -1078,8 +1078,8 @@ packages: '@types/react': '>=18' react: '>=18' - react-refresh@0.17.0: - resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} react@18.3.1: @@ -1549,7 +1549,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/rollup-android-arm-eabi@4.60.4': optional: true @@ -1698,14 +1698,14 @@ snapshots: '@ungap/structured-clone@1.3.1': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.9.1)(terser@5.48.0)(tsx@4.22.4))': + '@vitejs/plugin-react@5.2.0(vite@6.4.2(@types/node@25.9.1)(terser@5.48.0)(tsx@4.22.4))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) - '@rolldown/pluginutils': 1.0.0-beta.27 + '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 + react-refresh: 0.18.0 vite: 6.4.2(@types/node@25.9.1)(terser@5.48.0)(tsx@4.22.4) transitivePeerDependencies: - supports-color @@ -1714,19 +1714,19 @@ snapshots: bail@2.0.2: {} - baseline-browser-mapping@2.10.32: {} + baseline-browser-mapping@2.10.34: {} browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.32 - caniuse-lite: 1.0.30001793 - electron-to-chromium: 1.5.364 - node-releases: 2.0.46 + baseline-browser-mapping: 2.10.34 + caniuse-lite: 1.0.30001797 + electron-to-chromium: 1.5.370 + node-releases: 2.0.47 update-browserslist-db: 1.2.3(browserslist@4.28.2) buffer-from@1.1.2: {} - caniuse-lite@1.0.30001793: {} + caniuse-lite@1.0.30001797: {} ccount@2.0.1: {} @@ -1762,7 +1762,7 @@ snapshots: dependencies: dequal: 2.0.3 - electron-to-chromium@1.5.364: {} + electron-to-chromium@1.5.370: {} entities@6.0.1: {} @@ -2340,7 +2340,7 @@ snapshots: nanoid@3.3.12: {} - node-releases@2.0.46: {} + node-releases@2.0.47: {} parse-entities@4.0.2: dependencies: @@ -2392,7 +2392,7 @@ snapshots: transitivePeerDependencies: - supports-color - react-refresh@0.17.0: {} + react-refresh@0.18.0: {} react@18.3.1: dependencies: From 270fcdd0baa9265d226b1a75460585c9e2bc2d6a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:24:25 -0700 Subject: [PATCH 11/64] chore(deps): bump astro from 5.18.2 to 6.4.4 in /site (#3684) Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 5.18.2 to 6.4.4. - [Release notes](https://github.com/withastro/astro/releases) - [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md) - [Commits](https://github.com/withastro/astro/commits/astro@6.4.4/packages/astro) --- updated-dependencies: - dependency-name: astro dependency-version: 6.4.4 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- site/package-lock.json | 1308 +++++++++------------------------------- site/package.json | 2 +- 2 files changed, 302 insertions(+), 1008 deletions(-) diff --git a/site/package-lock.json b/site/package-lock.json index cdc424515..b3c2b04c0 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -7,34 +7,42 @@ "name": "reasonix-site", "dependencies": { "@fontsource-variable/inter": "^5.1.0", - "astro": "^5.7.0" + "astro": "^6.4.4" } }, "node_modules/@astrojs/compiler": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.1.tgz", - "integrity": "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-4.0.0.tgz", + "integrity": "sha512-eouss7G8ygdZqHuke033VMcVw5HTZUu+PXd/h06DGDUg/jt5btPYPqh66ENWw/mU78rBrf/oeC4oqoBwMtDMNA==", "license": "MIT" }, "node_modules/@astrojs/internal-helpers": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.6.tgz", - "integrity": "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q==", - "license": "MIT" + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.10.0.tgz", + "integrity": "sha512-Ry2R3VPeIN4uPCSA4xQc+e+vsJXkalKpEbDc07hV+a/o5Bs2N/s/uDcPJH/05L19DKh9tAy7e6JM3YZ6Cxfezw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.1.1", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" + } }, "node_modules/@astrojs/markdown-remark": { - "version": "6.3.11", - "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.11.tgz", - "integrity": "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.2.0.tgz", + "integrity": "sha512-+YxmVQu1Bd+MFfSzjq1rOJvD9+nIOJzz5YIIhdIH01RrxRkKbyKoEgyIqP3yv51MhzMDgd79QaPv+kCVPT8vHw==", "license": "MIT", "dependencies": { - "@astrojs/internal-helpers": "0.7.6", - "@astrojs/prism": "3.3.0", + "@astrojs/internal-helpers": "0.10.0", + "@astrojs/prism": "4.0.2", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", - "import-meta-resolve": "^4.2.0", - "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", @@ -42,39 +50,35 @@ "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", - "shiki": "^3.21.0", - "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", - "unist-util-visit": "^5.0.0", + "unist-util-visit": "^5.1.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "node_modules/@astrojs/prism": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-3.3.0.tgz", - "integrity": "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", + "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==", "license": "MIT", "dependencies": { "prismjs": "^1.30.0" }, "engines": { - "node": "18.20.8 || ^20.3.0 || >=22.0.0" + "node": ">=22.12.0" } }, "node_modules/@astrojs/telemetry": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.0.tgz", - "integrity": "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.2.tgz", + "integrity": "sha512-j8DNruA8ors99Al39RYZPJK4DC1bKkoNm93mAMuBhY9TCNC4R8n1q7ovFnJ5qhGh5Lsh7pa1gpQVpYpsJPeTHQ==", "license": "MIT", "dependencies": { - "ci-info": "^4.2.0", - "debug": "^4.4.0", - "dlv": "^1.1.3", + "ci-info": "^4.4.0", "dset": "^3.1.4", - "is-docker": "^3.0.0", - "is-wsl": "^3.1.0", + "is-docker": "^4.0.0", + "is-wsl": "^3.1.1", "which-pm-runs": "^1.1.0" }, "engines": { @@ -139,6 +143,34 @@ "node": ">=18" } }, + "node_modules/@clack/core": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.1.tgz", + "integrity": "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.5.1.tgz", + "integrity": "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.4.1", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", @@ -1406,64 +1438,97 @@ ] }, "node_modules/@shikijs/core": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", - "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.2.0.tgz", + "integrity": "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.23.0", + "@shikijs/primitive": "4.2.0", + "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/engine-javascript": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", - "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.2.0.tgz", + "integrity": "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.23.0", + "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.4" + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", - "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.2.0.tgz", + "integrity": "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.23.0", + "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/langs": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", - "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.2.0.tgz", + "integrity": "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.2.0.tgz", + "integrity": "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.23.0" + "@shikijs/types": "4.2.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/themes": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", - "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.2.0.tgz", + "integrity": "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w==", "license": "MIT", "dependencies": { - "@shikijs/types": "3.23.0" + "@shikijs/types": "4.2.0" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/types": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", - "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.2.0.tgz", + "integrity": "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw==", "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/vscode-textmate": { @@ -1532,92 +1597,6 @@ "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", "license": "ISC" }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ansi-align": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", - "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", - "license": "ISC", - "dependencies": { - "string-width": "^4.1.0" - } - }, - "node_modules/ansi-align/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-align/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/ansi-align/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-align/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -1669,80 +1648,72 @@ } }, "node_modules/astro": { - "version": "5.18.2", - "resolved": "https://registry.npmjs.org/astro/-/astro-5.18.2.tgz", - "integrity": "sha512-TnFwLnAXty5MXKPDGuKXqK4AMBXG+FH6RUdK7Oyc3gyfNoFIthT+4eRbzOK43bdRlLaZuxgciDSjgtggZ3OtGQ==", + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/astro/-/astro-6.4.4.tgz", + "integrity": "sha512-hVe8tq3lqt/Dr0UyB//yUmQSlHMTU8scTiF/vQddQVahLE4TTaSdH5H0nb7OvRcwo0UmlAO8DWYar4jNaS7H+A==", "license": "MIT", "dependencies": { - "@astrojs/compiler": "^2.13.0", - "@astrojs/internal-helpers": "0.7.6", - "@astrojs/markdown-remark": "6.3.11", - "@astrojs/telemetry": "3.3.0", + "@astrojs/compiler": "^4.0.0", + "@astrojs/internal-helpers": "0.10.0", + "@astrojs/markdown-remark": "7.2.0", + "@astrojs/telemetry": "3.3.2", "@capsizecss/unpack": "^4.0.0", + "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", - "acorn": "^8.15.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", - "boxen": "8.0.1", - "ci-info": "^4.3.1", + "ci-info": "^4.4.0", "clsx": "^2.1.1", - "common-ancestor-path": "^1.0.1", + "common-ancestor-path": "^2.0.0", "cookie": "^1.1.1", - "cssesc": "^3.0.0", - "debug": "^4.4.3", - "deterministic-object-hash": "^2.0.2", - "devalue": "^5.6.2", + "devalue": "^5.8.1", "diff": "^8.0.3", - "dlv": "^1.1.3", "dset": "^3.1.4", - "es-module-lexer": "^1.7.0", + "es-module-lexer": "^2.0.0", "esbuild": "^0.27.3", - "estree-walker": "^3.0.3", "flattie": "^1.1.1", - "fontace": "~0.4.0", + "fontace": "~0.4.1", + "get-tsconfig": "5.0.0-beta.4", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", - "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", + "jsonc-parser": "^3.3.1", "magic-string": "^0.30.21", - "magicast": "^0.5.1", + "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", - "p-limit": "^6.2.0", - "p-queue": "^8.1.1", + "obug": "^2.1.1", + "p-limit": "^7.3.0", + "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", - "picomatch": "^4.0.3", - "prompts": "^2.4.2", + "picomatch": "^4.0.4", "rehype": "^13.0.2", - "semver": "^7.7.3", - "shiki": "^3.21.0", + "semver": "^7.7.4", + "shiki": "^4.0.2", "smol-toml": "^1.6.0", - "svgo": "^4.0.0", - "tinyexec": "^1.0.2", + "svgo": "^4.0.1", + "tinyclip": "^0.1.12", + "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", - "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", - "unifont": "~0.7.3", - "unist-util-visit": "^5.0.0", - "unstorage": "^1.17.4", + "unifont": "~0.7.4", + "unist-util-visit": "^5.1.0", + "unstorage": "^1.17.5", "vfile": "^6.0.3", - "vite": "^6.4.1", - "vitefu": "^1.1.1", + "vite": "^7.3.2", + "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", - "yargs-parser": "^21.1.1", - "yocto-spinner": "^0.2.3", - "zod": "^3.25.76", - "zod-to-json-schema": "^3.25.1", - "zod-to-ts": "^1.2.0" + "yargs-parser": "^22.0.0", + "zod": "^4.3.6" }, "bin": { - "astro": "astro.js" + "astro": "bin/astro.mjs" }, "engines": { - "node": "18.20.8 || ^20.3.0 || >=22.0.0", + "node": ">=22.12.0", "npm": ">=9.6.5", "pnpm": ">=7.1.0" }, @@ -1773,52 +1744,12 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/base-64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", - "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", - "license": "MIT" - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, - "node_modules/boxen": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", - "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", - "license": "MIT", - "dependencies": { - "ansi-align": "^3.0.1", - "camelcase": "^8.0.0", - "chalk": "^5.3.0", - "cli-boxes": "^3.0.0", - "string-width": "^7.2.0", - "type-fest": "^4.21.0", - "widest-line": "^5.0.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/camelcase": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", - "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -1829,18 +1760,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -1901,18 +1820,6 @@ "node": ">=8" } }, - "node_modules/cli-boxes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", - "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1942,10 +1849,13 @@ } }, "node_modules/common-ancestor-path": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", - "integrity": "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==", - "license": "ISC" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", + "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } }, "node_modules/cookie": { "version": "1.1.1", @@ -2016,18 +1926,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/csso": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", @@ -2122,18 +2020,6 @@ "node": ">=8" } }, - "node_modules/deterministic-object-hash": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/deterministic-object-hash/-/deterministic-object-hash-2.0.2.tgz", - "integrity": "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==", - "license": "MIT", - "dependencies": { - "base-64": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/devalue": { "version": "5.8.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", @@ -2162,12 +2048,6 @@ "node": ">=0.3.1" } }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "license": "MIT" - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -2244,12 +2124,6 @@ "node": ">=4" } }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -2263,9 +2137,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "license": "MIT" }, "node_modules/esbuild": { @@ -2321,15 +2195,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -2342,6 +2207,30 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2403,16 +2292,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "node_modules/get-tsconfig": { + "version": "5.0.0-beta.4", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz", + "integrity": "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==", "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, "engines": { - "node": ">=18" + "node": ">=20.20.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, "node_modules/github-slugger": { @@ -2637,16 +2529,6 @@ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "license": "BSD-2-Clause" }, - "node_modules/import-meta-resolve": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", - "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/iron-webcrypto": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", @@ -2657,29 +2539,20 @@ } }, "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-4.0.0.tgz", + "integrity": "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==", "license": "MIT", "bin": { "is-docker": "cli.js" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -2698,6 +2571,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-inside-container/node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -2747,14 +2635,11 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "license": "MIT", - "engines": { - "node": ">=6" - } + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" }, "node_modules/longest-streak": { "version": "3.1.0", @@ -3687,6 +3572,19 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/ofetch": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", @@ -3722,43 +3620,43 @@ } }, "node_modules/p-limit": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-6.2.0.tgz", - "integrity": "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", + "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", "license": "MIT", "dependencies": { - "yocto-queue": "^1.1.1" + "yocto-queue": "^1.2.1" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-queue": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-8.1.1.tgz", - "integrity": "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.0.tgz", + "integrity": "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==", "license": "MIT", "dependencies": { - "eventemitter3": "^5.0.1", - "p-timeout": "^6.1.2" + "eventemitter3": "^5.0.4", + "p-timeout": "^7.0.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-timeout": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", - "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", "license": "MIT", "engines": { - "node": ">=14.16" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -3861,19 +3759,6 @@ "node": ">=6" } }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", @@ -4069,6 +3954,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/retext": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", @@ -4241,19 +4135,22 @@ } }, "node_modules/shiki": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", - "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.2.0.tgz", + "integrity": "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ==", "license": "MIT", "dependencies": { - "@shikijs/core": "3.23.0", - "@shikijs/engine-javascript": "3.23.0", - "@shikijs/engine-oniguruma": "3.23.0", - "@shikijs/langs": "3.23.0", - "@shikijs/themes": "3.23.0", - "@shikijs/types": "3.23.0", + "@shikijs/core": "4.2.0", + "@shikijs/engine-javascript": "4.2.0", + "@shikijs/engine-oniguruma": "4.2.0", + "@shikijs/langs": "4.2.0", + "@shikijs/themes": "4.2.0", + "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" } }, "node_modules/sisteransi": { @@ -4293,23 +4190,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -4324,21 +4204,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/svgo": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", @@ -4370,6 +4235,15 @@ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, + "node_modules/tinyclip": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.14.tgz", + "integrity": "sha512-F1oWdz8tjT17qe1d5JgDK6z03WGOhYYAN0lK3/D/fzNiy93xswLLEw7pk+3g05onhAy6Bsc6PLNUGhdgVjemMQ==", + "license": "MIT", + "engines": { + "node": "^16.14.0 || >= 17.3.0" + } + }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", @@ -4415,26 +4289,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/tsconfck": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", - "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", - "license": "MIT", - "bin": { - "tsconfck": "bin/tsconfck.js" - }, - "engines": { - "node": "^18 || >=20" - }, - "peerDependencies": { - "typescript": "^5.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4442,32 +4296,6 @@ "license": "0BSD", "optional": true }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/ufo": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", @@ -4778,23 +4606,23 @@ } }, "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -4803,14 +4631,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", - "less": "*", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -4851,463 +4679,6 @@ } } }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, "node_modules/vitefu": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", @@ -5346,38 +4717,6 @@ "node": ">=4" } }, - "node_modules/widest-line": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", - "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", - "license": "MIT", - "dependencies": { - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/xxhash-wasm": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", @@ -5385,12 +4724,12 @@ "license": "MIT" }, "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "license": "ISC", "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yocto-queue": { @@ -5405,60 +4744,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yocto-spinner": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-0.2.3.tgz", - "integrity": "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==", - "license": "MIT", - "dependencies": { - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": ">=18.19" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yoctocolors": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } - }, - "node_modules/zod-to-ts": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/zod-to-ts/-/zod-to-ts-1.2.0.tgz", - "integrity": "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==", - "peerDependencies": { - "typescript": "^4.9.4 || ^5.0.2", - "zod": "^3" - } - }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/site/package.json b/site/package.json index 563f66269..15596d5a0 100644 --- a/site/package.json +++ b/site/package.json @@ -8,7 +8,7 @@ "preview": "astro preview" }, "dependencies": { - "astro": "^5.7.0", + "astro": "^6.4.4", "@fontsource-variable/inter": "^5.1.0" } } From 56fca8acb7e6b4afe307e7c70fde17a7b030da28 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 9 Jun 2026 04:25:41 -0700 Subject: [PATCH 12/64] chore(ci): drop /npm/reasonix from Dependabot (#3692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /npm/reasonix only declares this project's own @reasonix/cli-* platform binaries as optionalDependencies — versioned by the release pipeline, not third-party deps. Dependabot was opening useless 0.0.0->1.0.0 self-bump PRs for them; remove the ecosystem entry. Co-authored-by: reasonix --- .github/dependabot.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 53b33bcd5..cd9cf45c1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -40,12 +40,6 @@ updates: patterns: ["*"] update-types: ["minor", "patch"] - - package-ecosystem: "npm" - directory: "/npm/reasonix" - schedule: - interval: "weekly" - open-pull-requests-limit: 5 - - package-ecosystem: "github-actions" directory: "/" schedule: From 0f4be9cb59d468974b8b6b16e7cb2301d0cde778 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:33:49 -0700 Subject: [PATCH 13/64] chore(deps): bump the actions group across 1 directory with 13 updates (#3688) Bumps the actions group with 13 updates in the / directory: | Package | From | To | | --- | --- | --- | | [actions/checkout](https://github.com/actions/checkout) | `4` | `6` | | [actions/setup-go](https://github.com/actions/setup-go) | `5` | `6` | | [pnpm/action-setup](https://github.com/pnpm/action-setup) | `4` | `6` | | [actions/setup-node](https://github.com/actions/setup-node) | `4` | `6` | | [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) | `7` | `9` | | [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4` | `7` | | [actions/github-script](https://github.com/actions/github-script) | `7` | `9` | | [actions/setup-python](https://github.com/actions/setup-python) | `5` | `6` | | [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) | `3` | `5` | | [actions/deploy-pages](https://github.com/actions/deploy-pages) | `4` | `5` | | [actions/labeler](https://github.com/actions/labeler) | `5` | `6` | | [actions/download-artifact](https://github.com/actions/download-artifact) | `4` | `8` | | [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) | `6` | `7` | Updates `actions/checkout` from 4 to 6 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) Updates `actions/setup-go` from 5 to 6 - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/v5...v6) Updates `pnpm/action-setup` from 4 to 6 - [Release notes](https://github.com/pnpm/action-setup/releases) - [Commits](https://github.com/pnpm/action-setup/compare/v4...v6) Updates `actions/setup-node` from 4 to 6 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v6) Updates `golangci/golangci-lint-action` from 7 to 9 - [Release notes](https://github.com/golangci/golangci-lint-action/releases) - [Commits](https://github.com/golangci/golangci-lint-action/compare/v7...v9) Updates `actions/upload-artifact` from 4 to 7 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) Updates `actions/github-script` from 7 to 9 - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v7...v9) Updates `actions/setup-python` from 5 to 6 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v6) Updates `actions/upload-pages-artifact` from 3 to 5 - [Release notes](https://github.com/actions/upload-pages-artifact/releases) - [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v5) Updates `actions/deploy-pages` from 4 to 5 - [Release notes](https://github.com/actions/deploy-pages/releases) - [Commits](https://github.com/actions/deploy-pages/compare/v4...v5) Updates `actions/labeler` from 5 to 6 - [Release notes](https://github.com/actions/labeler/releases) - [Commits](https://github.com/actions/labeler/compare/v5...v6) Updates `actions/download-artifact` from 4 to 8 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4...v8) Updates `goreleaser/goreleaser-action` from 6 to 7 - [Release notes](https://github.com/goreleaser/goreleaser-action/releases) - [Commits](https://github.com/goreleaser/goreleaser-action/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/deploy-pages dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/download-artifact dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/github-script dependency-version: '9' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/labeler dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/setup-go dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/setup-python dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/upload-pages-artifact dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: golangci/golangci-lint-action dependency-version: '9' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: goreleaser/goreleaser-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: pnpm/action-setup dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 32 +++++++++++------------ .github/workflows/codeql.yml | 2 +- .github/workflows/e2e-bot.yml | 8 +++--- .github/workflows/issue-auto-label.yml | 2 +- .github/workflows/issue-version-label.yml | 2 +- .github/workflows/pages.yml | 8 +++--- .github/workflows/pr-auto-label.yml | 2 +- .github/workflows/pr-version-label.yml | 2 +- .github/workflows/release-desktop.yml | 26 +++++++++--------- .github/workflows/release-npm.yml | 10 +++---- .github/workflows/release.yml | 12 ++++----- 11 files changed, 53 insertions(+), 53 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f43329e4e..92527cf9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,9 +21,9 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true @@ -58,9 +58,9 @@ jobs: race: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true @@ -79,20 +79,20 @@ jobs: run: working-directory: desktop steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: desktop/go.mod cache: true cache-dependency-path: desktop/go.sum - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: version: 10 run_install: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: "24" cache: pnpm @@ -145,15 +145,15 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true - name: golangci-lint - uses: golangci/golangci-lint-action@v7 + uses: golangci/golangci-lint-action@v9 with: version: v2.12.2 args: --timeout=5m @@ -162,9 +162,9 @@ jobs: runs-on: ubuntu-latest continue-on-error: true # informational — stdlib vulns need a Go patch release steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true @@ -178,9 +178,9 @@ jobs: coverage: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true @@ -189,7 +189,7 @@ jobs: run: go test -coverprofile=coverage.out -covermode=atomic ./... - name: upload coverage - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: coverage-report path: coverage.out diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0ae666d51..413818f9a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,7 +28,7 @@ jobs: - language: actions build-mode: none steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: diff --git a/.github/workflows/e2e-bot.yml b/.github/workflows/e2e-bot.yml index 6d7685f42..08d522b4a 100644 --- a/.github/workflows/e2e-bot.yml +++ b/.github/workflows/e2e-bot.yml @@ -27,7 +27,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Acknowledge - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | await github.rest.reactions.createForIssueComment({ @@ -37,16 +37,16 @@ jobs: # Default-branch checkout: this is where the harness (cmd/e2ebench), the # suite, and a run --metrics-capable agent live. - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version: '1.22' cache: true - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: '3.12' diff --git a/.github/workflows/issue-auto-label.yml b/.github/workflows/issue-auto-label.yml index ef232ea55..7b1a9c0b1 100644 --- a/.github/workflows/issue-auto-label.yml +++ b/.github/workflows/issue-auto-label.yml @@ -20,7 +20,7 @@ jobs: classify: runs-on: ubuntu-latest steps: - - uses: actions/github-script@v7 + - uses: actions/github-script@v9 env: DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} with: diff --git a/.github/workflows/issue-version-label.yml b/.github/workflows/issue-version-label.yml index 7e3256abf..81c9578fb 100644 --- a/.github/workflows/issue-version-label.yml +++ b/.github/workflows/issue-version-label.yml @@ -18,7 +18,7 @@ jobs: label: runs-on: ubuntu-latest steps: - - uses: actions/github-script@v7 + - uses: actions/github-script@v9 with: script: | const body = context.payload.issue.body || ''; diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index d805453e4..a2d9fc755 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -19,8 +19,8 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version: '22' cache: npm @@ -30,7 +30,7 @@ jobs: run: | npm ci npm run build - - uses: actions/upload-pages-artifact@v3 + - uses: actions/upload-pages-artifact@v5 with: path: site/dist @@ -42,4 +42,4 @@ jobs: url: ${{ steps.deploy.outputs.page_url }} steps: - id: deploy - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/pr-auto-label.yml b/.github/workflows/pr-auto-label.yml index 6d8455182..6f1b429b7 100644 --- a/.github/workflows/pr-auto-label.yml +++ b/.github/workflows/pr-auto-label.yml @@ -23,6 +23,6 @@ jobs: label: runs-on: ubuntu-latest steps: - - uses: actions/labeler@v5 + - uses: actions/labeler@v6 with: sync-labels: false diff --git a/.github/workflows/pr-version-label.yml b/.github/workflows/pr-version-label.yml index e34908ab0..d2d764e4d 100644 --- a/.github/workflows/pr-version-label.yml +++ b/.github/workflows/pr-version-label.yml @@ -20,7 +20,7 @@ jobs: label: runs-on: ubuntu-latest steps: - - uses: actions/github-script@v7 + - uses: actions/github-script@v9 with: script: | const base = context.payload.pull_request.base.ref; diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 214994966..91d8c8137 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -42,9 +42,9 @@ jobs: name: cache hit guard runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true @@ -69,18 +69,18 @@ jobs: run: shell: bash # desktop-build.sh is bash; windows runners default to pwsh otherwise steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: desktop/go.mod cache: true cache-dependency-path: desktop/go.sum - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: "22" - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v6 with: version: 10 @@ -169,7 +169,7 @@ jobs: MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }} run: go run ./cmd/sign sign ../dist/* - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: dist-${{ matrix.name }} path: dist/* @@ -183,15 +183,15 @@ jobs: # approve); canary uses the open `canary` environment so maintainers self-serve. environment: ${{ (github.event_name == 'workflow_dispatch' && inputs.channel == 'canary') && 'canary' || 'release' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: desktop/go.mod cache: true cache-dependency-path: desktop/go.sum - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: path: dist pattern: dist-* @@ -228,7 +228,7 @@ jobs: - name: Upload canary dist for mirror if: steps.ver.outputs.channel == 'canary' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: canary-dist path: dist/* @@ -243,7 +243,7 @@ jobs: env: HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Resolve version id: ver @@ -260,7 +260,7 @@ jobs: # artifact. Stable pulls from the published release. - name: Download canary dist if: env.HAS_R2 == 'true' && steps.ver.outputs.channel == 'canary' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: canary-dist path: assets diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index 3f01aaf2b..d50bab73a 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -33,8 +33,8 @@ jobs: name: cache hit guard runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true @@ -48,12 +48,12 @@ jobs: # `release` environment (esengine approves); a canary dispatch runs free. environment: ${{ github.event_name == 'workflow_dispatch' && 'canary' || 'release' }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: '22' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efb7ea10a..0d47986c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,8 +19,8 @@ jobs: name: cache hit guard runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true @@ -34,14 +34,14 @@ jobs: # `release` environment so only esengine can approve it going public. environment: release steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true - - uses: goreleaser/goreleaser-action@v6 + - uses: goreleaser/goreleaser-action@v7 with: version: '~> v2' args: release --clean @@ -57,7 +57,7 @@ jobs: env: HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download CodeGraph release assets if: env.HAS_R2 == 'true' From f5b8923381f32afec8c5467f739bcf9ca9813701 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:33:55 -0700 Subject: [PATCH 14/64] chore(deps): bump react-markdown in /desktop/frontend (#3689) Bumps [react-markdown](https://github.com/remarkjs/react-markdown) from 9.1.0 to 10.1.0. - [Release notes](https://github.com/remarkjs/react-markdown/releases) - [Changelog](https://github.com/remarkjs/react-markdown/blob/main/changelog.md) - [Commits](https://github.com/remarkjs/react-markdown/compare/9.1.0...10.1.0) --- updated-dependencies: - dependency-name: react-markdown dependency-version: 10.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- desktop/frontend/package-lock.json | 8 ++++---- desktop/frontend/package.json | 2 +- desktop/frontend/pnpm-lock.yaml | 31 +++++++++++++++++------------- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/desktop/frontend/package-lock.json b/desktop/frontend/package-lock.json index ef8e307cc..a5bc64fbb 100644 --- a/desktop/frontend/package-lock.json +++ b/desktop/frontend/package-lock.json @@ -14,7 +14,7 @@ "lucide-react": "^0.460.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-markdown": "^9.0.1", + "react-markdown": "^10.1.0", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0" @@ -3133,9 +3133,9 @@ } }, "node_modules/react-markdown": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", - "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index 533614e18..34a2b6e3c 100644 --- a/desktop/frontend/package.json +++ b/desktop/frontend/package.json @@ -21,7 +21,7 @@ "lucide-react": "^0.460.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-markdown": "^9.0.1", + "react-markdown": "^10.1.0", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.0", "remark-math": "^6.0.0" diff --git a/desktop/frontend/pnpm-lock.yaml b/desktop/frontend/pnpm-lock.yaml index f6b116da4..2f93bfe68 100644 --- a/desktop/frontend/pnpm-lock.yaml +++ b/desktop/frontend/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) react-markdown: - specifier: ^9.0.1 - version: 9.1.0(@types/react@18.3.29)(react@18.3.1) + specifier: ^10.1.0 + version: 10.1.0(@types/react@18.3.29)(react@18.3.1) rehype-katex: specifier: ^7.0.1 version: 7.0.1 @@ -641,6 +641,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -1064,16 +1067,16 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} - property-information@7.1.0: - resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: react: ^18.3.1 - react-markdown@9.1.0: - resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==} + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: '@types/react': '>=18' react: '>=18' @@ -1661,10 +1664,12 @@ snapshots: '@types/estree-jsx@1.0.5': dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -1869,7 +1874,7 @@ snapshots: '@types/unist': 3.0.3 devlop: 1.1.0 hastscript: 9.0.1 - property-information: 7.1.0 + property-information: 7.2.0 vfile: 6.0.3 vfile-location: 5.0.3 web-namespaces: 2.0.1 @@ -1884,7 +1889,7 @@ snapshots: hast-util-to-jsx-runtime@2.3.6: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/hast': 3.0.4 '@types/unist': 3.0.3 comma-separated-tokens: 2.0.3 @@ -1894,7 +1899,7 @@ snapshots: mdast-util-mdx-expression: 2.0.1 mdast-util-mdx-jsx: 3.2.0 mdast-util-mdxjs-esm: 2.0.1 - property-information: 7.1.0 + property-information: 7.2.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 unist-util-position: 5.0.0 @@ -1918,7 +1923,7 @@ snapshots: '@types/hast': 3.0.4 comma-separated-tokens: 2.0.3 hast-util-parse-selector: 4.0.0 - property-information: 7.1.0 + property-information: 7.2.0 space-separated-tokens: 2.0.2 highlight.js@11.11.1: {} @@ -2366,7 +2371,7 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - property-information@7.1.0: {} + property-information@7.2.0: {} react-dom@18.3.1(react@18.3.1): dependencies: @@ -2374,7 +2379,7 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-markdown@9.1.0(@types/react@18.3.29)(react@18.3.1): + react-markdown@10.1.0(@types/react@18.3.29)(react@18.3.1): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 From 450c000595ef4ad129899d3737fd14ba23c7fc8c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:34:01 -0700 Subject: [PATCH 15/64] chore(deps): bump lucide-react in /desktop/frontend (#3686) Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.460.0 to 1.17.0. - [Release notes](https://github.com/lucide-icons/lucide/releases) - [Commits](https://github.com/lucide-icons/lucide/commits/1.17.0/packages/lucide-react) --- updated-dependencies: - dependency-name: lucide-react dependency-version: 1.17.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- desktop/frontend/package-lock.json | 10 +++++----- desktop/frontend/package.json | 2 +- desktop/frontend/pnpm-lock.yaml | 12 ++++++------ 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/desktop/frontend/package-lock.json b/desktop/frontend/package-lock.json index a5bc64fbb..3e67c71e8 100644 --- a/desktop/frontend/package-lock.json +++ b/desktop/frontend/package-lock.json @@ -11,7 +11,7 @@ "@tanstack/react-virtual": "^3.14.2", "highlight.js": "^11.10.0", "katex": "^0.17.0", - "lucide-react": "^0.460.0", + "lucide-react": "^1.17.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", @@ -2071,12 +2071,12 @@ } }, "node_modules/lucide-react": { - "version": "0.460.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.460.0.tgz", - "integrity": "sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", + "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", "license": "ISC", "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/markdown-table": { diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index 34a2b6e3c..ba0974593 100644 --- a/desktop/frontend/package.json +++ b/desktop/frontend/package.json @@ -18,7 +18,7 @@ "@tanstack/react-virtual": "^3.14.2", "highlight.js": "^11.10.0", "katex": "^0.17.0", - "lucide-react": "^0.460.0", + "lucide-react": "^1.17.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", diff --git a/desktop/frontend/pnpm-lock.yaml b/desktop/frontend/pnpm-lock.yaml index 2f93bfe68..e7f0748b3 100644 --- a/desktop/frontend/pnpm-lock.yaml +++ b/desktop/frontend/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^0.17.0 version: 0.17.0 lucide-react: - specifier: ^0.460.0 - version: 0.460.0(react@18.3.1) + specifier: ^1.17.0 + version: 1.17.0(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -895,10 +895,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@0.460.0: - resolution: {integrity: sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==} + lucide-react@1.17.0: + resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==} peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -1969,7 +1969,7 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@0.460.0(react@18.3.1): + lucide-react@1.17.0(react@18.3.1): dependencies: react: 18.3.1 From 8db604e0bd1084f953a55a63150403a03bc0a59c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 04:51:15 -0700 Subject: [PATCH 16/64] chore(deps): bump the go group across 1 directory with 3 updates (#3678) Bumps the go group with 3 updates in the / directory: [golang.org/x/sys](https://github.com/golang/sys), [golang.org/x/term](https://github.com/golang/term) and [golang.org/x/text](https://github.com/golang/text). Updates `golang.org/x/sys` from 0.45.0 to 0.46.0 - [Commits](https://github.com/golang/sys/compare/v0.45.0...v0.46.0) Updates `golang.org/x/term` from 0.43.0 to 0.44.0 - [Commits](https://github.com/golang/term/compare/v0.43.0...v0.44.0) Updates `golang.org/x/text` from 0.37.0 to 0.38.0 - [Release notes](https://github.com/golang/text/releases) - [Commits](https://github.com/golang/text/compare/v0.37.0...v0.38.0) --- updated-dependencies: - dependency-name: golang.org/x/sys dependency-version: 0.46.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go - dependency-name: golang.org/x/term dependency-version: 0.44.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go - dependency-name: golang.org/x/text dependency-version: 0.38.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: go ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index df5e51c7d..d046b3d67 100644 --- a/go.mod +++ b/go.mod @@ -17,9 +17,9 @@ require ( github.com/yuin/goldmark v1.8.2 go.uber.org/goleak v1.3.0 golang.org/x/net v0.55.0 - golang.org/x/sys v0.45.0 - golang.org/x/term v0.43.0 - golang.org/x/text v0.37.0 + golang.org/x/sys v0.46.0 + golang.org/x/term v0.44.0 + golang.org/x/text v0.38.0 ) require ( @@ -35,5 +35,5 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.20.0 // indirect + golang.org/x/sync v0.21.0 // indirect ) diff --git a/go.sum b/go.sum index 70736883e..3aa60a3bd 100644 --- a/go.sum +++ b/go.sum @@ -69,14 +69,14 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= From 24e00e2ea0962790b5ad10cb1952ed75e22d5630 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 9 Jun 2026 05:47:19 -0700 Subject: [PATCH 17/64] test: structurally guard nil-deref so staticcheck SA5011 can't misfire (#3706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release golangci-lint v2.12.2 binary CI runs flags SA5011 (possible nil pointer dereference) on two `if x == nil { t.Fatal(...) }`-guarded derefs, even though they are correct (Fatal stops the test). The finding was masked by the golangci-lint-action cache until #3678 changed go.sum and invalidated it, then surfaced on main-v2 and every open PR. A locally-built golangci-lint does not reproduce it, so this is a staticcheck quirk in the pinned binary, not a real defect. Restructure both sites with flow-based guards (else-if / switch) the analyzer always respects — no behaviour change, no unreachable `return` (which a Fatal-aware analyzer would flag locally). Co-authored-by: reasonix --- internal/control/shell_test.go | 3 +-- internal/provider/anthropic/anthropic_test.go | 12 +++++------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/internal/control/shell_test.go b/internal/control/shell_test.go index cd3191f0d..118b26e16 100644 --- a/internal/control/shell_test.go +++ b/internal/control/shell_test.go @@ -135,8 +135,7 @@ func TestRunShell_FailingCommand(t *testing.T) { } if result == nil { t.Fatal("expected a ToolResult event") - } - if result.Tool.Err == "" { + } else if result.Tool.Err == "" { t.Error("failing command should produce an error string") } } diff --git a/internal/provider/anthropic/anthropic_test.go b/internal/provider/anthropic/anthropic_test.go index 7614592b3..cfb99d49d 100644 --- a/internal/provider/anthropic/anthropic_test.go +++ b/internal/provider/anthropic/anthropic_test.go @@ -186,16 +186,14 @@ func TestReadStream(t *testing.T) { if full == nil || full.Arguments != `{"city":"Paris"}` { t.Fatalf("tool full = %+v", full) } - if usage == nil { + switch { + case usage == nil: t.Fatal("expected a usage chunk") - } - if usage.PromptTokens != 150 || usage.CompletionTokens != 25 || usage.TotalTokens != 175 { + case usage.PromptTokens != 150 || usage.CompletionTokens != 25 || usage.TotalTokens != 175: t.Fatalf("usage tokens = %+v", usage) - } - if usage.CacheHitTokens != 50 || usage.CacheMissTokens != 100 { + case usage.CacheHitTokens != 50 || usage.CacheMissTokens != 100: t.Fatalf("usage cache = hit %d miss %d", usage.CacheHitTokens, usage.CacheMissTokens) - } - if usage.FinishReason != "tool_calls" { + case usage.FinishReason != "tool_calls": t.Fatalf("finish reason = %q", usage.FinishReason) } if !done { From f9b6d5765dc48c1b2695062bd8fa74747ed17397 Mon Sep 17 00:00:00 2001 From: paradoxSCH <64133628+paradoxSCH@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:59:44 +0800 Subject: [PATCH 18/64] fix(agent): allow pending todo signoff (#3673) --- internal/agent/evidence_flow_test.go | 62 ++++++++++++++++++++++ internal/tool/builtin/completestep.go | 6 +-- internal/tool/builtin/completestep_test.go | 27 ++++++++-- 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/internal/agent/evidence_flow_test.go b/internal/agent/evidence_flow_test.go index 202ea579a..711267392 100644 --- a/internal/agent/evidence_flow_test.go +++ b/internal/agent/evidence_flow_test.go @@ -607,6 +607,68 @@ func TestEvidenceFlowRejectsTodoCompletionWithoutCompleteStep(t *testing.T) { } } +func TestEvidenceFlowRecoversAfterBatchTodoCompletionRejection(t *testing.T) { + todoWrite, ok := tool.LookupBuiltin("todo_write") + if !ok { + t.Fatal("todo_write builtin not registered") + } + completeStep, ok := tool.LookupBuiltin("complete_step") + if !ok { + t.Fatal("complete_step builtin not registered") + } + reg := tool.NewRegistry() + reg.Add(todoWrite) + reg.Add(completeStep) + + prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{ + { + toolCallChunk("c1", "todo_write", `{"todos":[ + {"content":"Port entity imports","status":"in_progress"}, + {"content":"Run build and tests","status":"pending"} + ]}`), + toolCallChunk("c2", "todo_write", `{"todos":[ + {"content":"Port entity imports","status":"completed"}, + {"content":"Run build and tests","status":"completed"} + ]}`), + {Type: provider.ChunkDone}, + }, + { + toolCallChunk("c3", "complete_step", `{ + "step":"Port entity imports", + "result":"entity imports ported", + "evidence":[{"kind":"manual","summary":"checked manually"}] + }`), + toolCallChunk("c4", "complete_step", `{ + "step":"Run build and tests", + "result":"build and tests ran", + "evidence":[{"kind":"manual","summary":"checked manually"}] + }`), + toolCallChunk("c5", "todo_write", `{"todos":[ + {"content":"Port entity imports","status":"completed"}, + {"content":"Run build and tests","status":"completed"} + ]}`), + {Type: provider.ChunkDone}, + }, + {{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}}, + }} + + a := New(prov, reg, NewSession(""), Options{}, event.Discard) + if err := a.Run(context.Background(), "recover from a rejected batch todo update"); err != nil { + t.Fatalf("Run: %v", err) + } + + stepResults := toolResults(a.session, "complete_step") + if len(stepResults) < 2 { + t.Fatalf("complete_step results = %v, want two sign-offs", stepResults) + } + if got := stepResults[1]; !strings.Contains(got, "signed off") { + t.Fatalf("pending todo complete_step result = %q, want successful sign-off", got) + } + if got := lastToolResult(a.session, "todo_write"); !strings.Contains(got, "2 completed") { + t.Fatalf("final todo_write result = %q, want all todos completed", got) + } +} + func TestEvidenceFlowFailedCompleteStepDoesNotAuthorizeTodoCompletion(t *testing.T) { todoWrite, ok := tool.LookupBuiltin("todo_write") if !ok { diff --git a/internal/tool/builtin/completestep.go b/internal/tool/builtin/completestep.go index 019f33a40..3640f4e7b 100644 --- a/internal/tool/builtin/completestep.go +++ b/internal/tool/builtin/completestep.go @@ -235,12 +235,10 @@ func verifyTodoStep(ctx context.Context, step string) (evidence.TodoStepMatch, b return evidence.TodoStepMatch{}, true, fmt.Errorf("step %q has no matching todo_write item in this turn", step) } switch match.Status { - case "in_progress", "completed": + case "", "pending", "in_progress", "completed": return match, true, nil - case "": - return evidence.TodoStepMatch{}, true, fmt.Errorf("step %q matches todo %d (%q) but its status is pending; complete_step requires in_progress or completed", step, match.Index, match.Content) default: - return evidence.TodoStepMatch{}, true, fmt.Errorf("step %q matches todo %d (%q) but its status is %q; complete_step requires in_progress or completed", step, match.Index, match.Content, match.Status) + return evidence.TodoStepMatch{}, true, fmt.Errorf("step %q matches todo %d (%q) but its status is %q; complete_step requires pending, in_progress, or completed", step, match.Index, match.Content, match.Status) } } diff --git a/internal/tool/builtin/completestep_test.go b/internal/tool/builtin/completestep_test.go index 67a4242a8..7e5843701 100644 --- a/internal/tool/builtin/completestep_test.go +++ b/internal/tool/builtin/completestep_test.go @@ -273,7 +273,7 @@ func TestCompleteStepMatchesTodoReceipt(t *testing.T) { } } -func TestCompleteStepRejectsTodoMismatchAndPending(t *testing.T) { +func TestCompleteStepRejectsTodoMismatch(t *testing.T) { ledger := evidence.NewLedger() ledger.Record(evidence.Receipt{ ToolName: "todo_write", @@ -291,8 +291,6 @@ func TestCompleteStepRejectsTodoMismatchAndPending(t *testing.T) { want string }{ {name: "missing", step: "Ship parser", want: "matching todo_write item"}, - {name: "pending", step: "Document parser", want: "pending"}, - {name: "pending number", step: "2", want: "pending"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -310,6 +308,29 @@ func TestCompleteStepRejectsTodoMismatchAndPending(t *testing.T) { } } +func TestCompleteStepAcceptsPendingTodo(t *testing.T) { + ledger := evidence.NewLedger() + ledger.Record(evidence.Receipt{ + ToolName: "todo_write", + Success: true, + Todos: []evidence.TodoItem{ + {Content: "Add parser", Status: "pending"}, + }, + }) + ctx := evidence.WithLedger(context.Background(), ledger) + + out, err := completeStep{}.Execute(ctx, json.RawMessage(`{ + "step":"Add parser", + "result":"parser added", + "evidence":[{"kind":"manual","summary":"checked manually"}]}`)) + if err != nil { + t.Fatalf("pending todo should be signable with evidence: %v", err) + } + if !strings.Contains(out, "todo-matched") { + t.Fatalf("ack should mention todo match, got %q", out) + } +} + func TestCompleteStepIgnoresFailedTodoReceipt(t *testing.T) { ledger := evidence.NewLedger() ledger.Record(evidence.Receipt{ From 1b7cdbeeb0c2fd285942a477510e5519d0a8696b Mon Sep 17 00:00:00 2001 From: CVEngineer <129239603+CVEngineer66@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:04:06 +0800 Subject: [PATCH 19/64] fix(desktop): remove command text from approval rule buttons, causing layout breakage (#3700) The 'Allow for session' and 'Always allow' buttons in the approval modal displayed the full bash command as inline code (e.g. Bash(cd ...)), which stretched the button elements and broke the card layout. The command is already visible in the card header meta area and the expandable Details section, so showing it again inside the action buttons is redundant. Removed the RuleActionLabel component, the rule-formatting helpers (approvalSessionRule, approvalPersistentRule, truncateSubject), and the unused CSS classes. Fixes https://github.com/esengine/DeepSeek-Reasonix/issues/3677 Co-authored-by: wufengfan --- .../frontend/src/components/ApprovalModal.tsx | 40 +++---------------- desktop/frontend/src/styles.css | 25 ------------ 2 files changed, 5 insertions(+), 60 deletions(-) diff --git a/desktop/frontend/src/components/ApprovalModal.tsx b/desktop/frontend/src/components/ApprovalModal.tsx index e6726be88..4a9920c6d 100644 --- a/desktop/frontend/src/components/ApprovalModal.tsx +++ b/desktop/frontend/src/components/ApprovalModal.tsx @@ -26,9 +26,7 @@ export function ApprovalModal({ const bashPrefix = isBashApproval ? bashCommandPrefix(subject) : ""; const hasBashPrefix = bashPrefix !== ""; const subjectSummary = subject.split("\n").find((line) => line.trim())?.trim() ?? ""; - const exactSessionRule = approvalSessionRule(approval.tool, subject); - const exactPersistentRule = approvalPersistentRule(approval.tool, subject); - const prefixRule = hasBashPrefix ? `Bash(${bashPrefix})` : ""; + const choosePlanAction = (key: string) => { if (key === "1") setRevisionOpen((open) => !open); @@ -154,12 +152,12 @@ export function ApprovalModal({ <> } + label={t("approval.allowRuleSession")} onClick={() => onAnswer(true, true, false, "prefix")} /> } + label={t("approval.allowRulePersistent")} onClick={() => onAnswer(true, true, true, "prefix")} /> onAnswer(false, false, false)} /> @@ -168,12 +166,12 @@ export function ApprovalModal({ <> } + label={t("approval.allowRuleSession")} onClick={() => onAnswer(true, true, false)} /> } + label={t("approval.allowRulePersistent")} onClick={() => onAnswer(true, true, true)} /> onAnswer(false, false, false)} /> @@ -189,26 +187,7 @@ export function ApprovalModal({ ); } -function RuleActionLabel({ label, rule }: { label: string; rule: string }) { - return ( - - {label} - {rule} - - ); -} -function approvalSessionRule(tool: string, subject: string): string { - if (tool === "bash" && subject) return `Bash(${subject})`; - if (isFileMutationTool(tool)) return "Edit"; - return tool; -} - -function approvalPersistentRule(tool: string, subject: string): string { - if (tool === "bash" && subject) return `Bash(${subject})`; - if (isFileMutationTool(tool)) return subject ? `Edit(${subject})` : "Edit"; - return tool; -} function bashCommandPrefix(subject: string): string { const command = subject.trim(); @@ -236,12 +215,3 @@ function dangerousBashCommand(command: string): boolean { || /^dd\s+if=/.test(command) || /^fdisk\b/.test(command); } - -function isFileMutationTool(tool: string): boolean { - return tool === "write_file" - || tool === "edit_file" - || tool === "multi_edit" - || tool === "notebook_edit" - || tool === "delete_range" - || tool === "delete_symbol"; -} diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index 9ff15e4d4..068009e09 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -3369,15 +3369,6 @@ a[href] { line-height: 1.4; padding-block: 1px; } -.prompt-action__label:has(.approval-rule-label) { - overflow: visible; - text-overflow: clip; - white-space: normal; -} -.prompt-action:has(.approval-rule-label) { - height: auto; - padding-block: 5px; -} .prompt-detail-toggle { flex: 0 0 auto; max-width: 92px; @@ -3435,22 +3426,6 @@ a[href] { max-height: 150px; overflow: auto; } -.approval-rule-label { - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; - max-width: 100%; -} -.approval-rule-label code { - min-width: 0; - max-width: 24ch; - overflow-wrap: anywhere; - font-family: var(--mono); - font-size: 11px; - color: var(--fg); -} - /* ── ask tool: decision details ───────────────────────────────────────────── */ .ask-shelf__crumbs { display: flex; From 3c32366ad14e8ff6bfee4e72a018290448eaeb2e Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 9 Jun 2026 06:22:01 -0700 Subject: [PATCH 20/64] fix(control): don't auto-answer the ask tool in YOLO mode (#3624) (#3712) * fix(control): don't auto-answer the ask tool in YOLO mode Controller.Ask checked bypassEnabled() and silently returned the first option of every question, so in YOLO mode the `ask` tool got a fake answer and the user never saw the prompt. Bypass is meant to skip tool-approval gates, not to answer the user's genuine questions. Remove both bypass checks (and the now unused recommendedAskAnswers helper); Ask always emits an AskRequest and waits for AnswerQuestion, even under bypass. Combines the minimal fix from #3709 (CVEngineer66) with the behavior tests from #3638 (warvyvr), dropping that PR's unrelated files. Closes #3624 Co-authored-by: CVEngineer66 Co-authored-by: warvyvr * fix(control): drop now-unused bypassEnabled helper Removing the bypass checks from Ask left bypassEnabled with no callers (staticcheck unused). --------- Co-authored-by: reasonix Co-authored-by: CVEngineer66 Co-authored-by: warvyvr --- internal/control/controller.go | 28 +------- internal/control/yolo_test.go | 116 +++++++++++++++++++++++---------- 2 files changed, 85 insertions(+), 59 deletions(-) diff --git a/internal/control/controller.go b/internal/control/controller.go index b11dbcb1b..24a132d6b 100644 --- a/internal/control/controller.go +++ b/internal/control/controller.go @@ -858,18 +858,13 @@ func (c *Controller) EnableInteractiveApproval() { // Ask implements agent.Asker: it emits an AskRequest and blocks until // AnswerQuestion(ID, …) answers or ctx is cancelled. promptMu serialises it // against tool-approval prompts so at most one user prompt is outstanding. +// Unlike tool-approval gates, Ask is NOT bypassed in YOLO mode — the `ask` +// tool exists to get a genuine user decision, and YOLO only auto-approves +// tool calls; it must not answer the user's questions for them. func (c *Controller) Ask(ctx context.Context, questions []event.AskQuestion) ([]event.AskAnswer, error) { - if c.bypassEnabled() { - return recommendedAskAnswers(questions), nil - } - c.promptMu.Lock() defer c.promptMu.Unlock() - if c.bypassEnabled() { - return recommendedAskAnswers(questions), nil - } - c.mu.Lock() c.nextID++ id := strconv.Itoa(c.nextID) @@ -890,23 +885,6 @@ func (c *Controller) Ask(ctx context.Context, questions []event.AskQuestion) ([] } } -func (c *Controller) bypassEnabled() bool { - c.mu.Lock() - defer c.mu.Unlock() - return c.bypass -} - -func recommendedAskAnswers(questions []event.AskQuestion) []event.AskAnswer { - out := make([]event.AskAnswer, len(questions)) - for i, q := range questions { - out[i] = event.AskAnswer{QuestionID: q.ID} - if len(q.Options) > 0 { - out[i].Selected = []string{q.Options[0].Label} - } - } - return out -} - // AnswerQuestion resolves a pending AskRequest by ID with the user's selections. // Unknown/expired IDs are ignored. func (c *Controller) AnswerQuestion(id string, answers []event.AskAnswer) { diff --git a/internal/control/yolo_test.go b/internal/control/yolo_test.go index 8b9e33140..f39cd0463 100644 --- a/internal/control/yolo_test.go +++ b/internal/control/yolo_test.go @@ -3,7 +3,6 @@ package control import ( "context" "strings" - "sync" "testing" "time" @@ -184,18 +183,18 @@ func TestSetModeAppliesBothGates(t *testing.T) { } } -func TestBypassAutoAnswersAskWithRecommendedOptions(t *testing.T) { - var askRequested bool +func TestBypassDoesNotAutoAnswerAsk(t *testing.T) { + askCh := make(chan event.Ask, 1) c := New(Options{ Sink: event.FuncSink(func(e event.Event) { if e.Kind == event.AskRequest { - askRequested = true + askCh <- e.Ask } }), }) c.SetBypass(true) - answers, err := c.Ask(context.Background(), []event.AskQuestion{ + questions := []event.AskQuestion{ { ID: "approach", Header: "Approach", @@ -215,33 +214,69 @@ func TestBypassAutoAnswersAskWithRecommendedOptions(t *testing.T) { }, Multi: true, }, + } + + done := make(chan struct { + answers []event.AskAnswer + err error + }, 1) + go func() { + answers, err := c.Ask(context.Background(), questions) + done <- struct { + answers []event.AskAnswer + err error + }{answers, err} + }() + + // Even with bypass on, Ask must emit an AskRequest and wait for the user. + var ask event.Ask + select { + case ask = <-askCh: + case <-time.After(2 * time.Second): + t.Fatal("Ask did not emit AskRequest under bypass; bypass should not auto-answer ask") + } + + // Answer with NON-recommended options to prove the user was consulted. + c.AnswerQuestion(ask.ID, []event.AskAnswer{ + {QuestionID: "approach", Selected: []string{"Alternative path"}}, + {QuestionID: "scope", Selected: []string{"Broad"}}, }) - if err != nil { - t.Fatalf("Ask: %v", err) + + var result struct { + answers []event.AskAnswer + err error + } + select { + case result = <-done: + case <-time.After(2 * time.Second): + t.Fatal("Ask stayed blocked after AnswerQuestion") } - if askRequested { - t.Fatal("bypass must not emit an AskRequest event") + if result.err != nil { + t.Fatalf("Ask: %v", result.err) } - want := []event.AskAnswer{ - {QuestionID: "approach", Selected: []string{"Recommended path"}}, - {QuestionID: "scope", Selected: []string{"Minimal"}}, + + wantAnswers := []event.AskAnswer{ + {QuestionID: "approach", Selected: []string{"Alternative path"}}, + {QuestionID: "scope", Selected: []string{"Broad"}}, } - if len(answers) != len(want) { - t.Fatalf("answers len = %d, want %d: %#v", len(answers), len(want), answers) + if len(result.answers) != len(wantAnswers) { + t.Fatalf("answers len = %d, want %d: %#v", len(result.answers), len(wantAnswers), result.answers) } - for i := range want { - if answers[i].QuestionID != want[i].QuestionID || len(answers[i].Selected) != 1 || answers[i].Selected[0] != want[i].Selected[0] { - t.Fatalf("answers[%d] = %#v, want %#v", i, answers[i], want[i]) + for i := range wantAnswers { + if result.answers[i].QuestionID != wantAnswers[i].QuestionID || + len(result.answers[i].Selected) != 1 || + result.answers[i].Selected[0] != wantAnswers[i].Selected[0] { + t.Fatalf("answers[%d] = %#v, want %#v", i, result.answers[i], wantAnswers[i]) } } } -func TestBypassRecheckedForAskAfterPromptLock(t *testing.T) { - askRequests := make(chan struct{}, 1) +func TestAskSerializesBehindPromptLockEvenWithBypass(t *testing.T) { + askCh := make(chan event.Ask, 1) c := New(Options{ Sink: event.FuncSink(func(e event.Event) { if e.Kind == event.AskRequest { - askRequests <- struct{}{} + askCh <- e.Ask } }), }) @@ -256,12 +291,9 @@ func TestBypassRecheckedForAskAfterPromptLock(t *testing.T) { }} c.promptMu.Lock() - started := make(chan struct{}) done := make(chan []event.AskAnswer, 1) errs := make(chan error, 1) - var once sync.Once go func() { - once.Do(func() { close(started) }) answers, err := c.Ask(context.Background(), questions) if err != nil { errs <- err @@ -269,26 +301,42 @@ func TestBypassRecheckedForAskAfterPromptLock(t *testing.T) { } done <- answers }() - <-started + // Give the goroutine time to block on promptMu. time.Sleep(20 * time.Millisecond) + // Pre-unlock assertion: while promptMu is still held, Ask must NOT have + // emitted AskRequest — proving it truly serialized behind the lock. + select { + case <-askCh: + t.Fatal("Ask emitted AskRequest before acquiring promptMu; it did not serialize behind the lock") + default: + } + + // Enable bypass while Ask is queued behind promptMu. c.SetBypass(true) + // Release the lock — Ask proceeds but must still emit an AskRequest. c.promptMu.Unlock() + // Post-unlock assertion: Ask must emit AskRequest now that it holds the lock. + var ask event.Ask select { - case err := <-errs: - t.Fatalf("Ask: %v", err) - case answers := <-done: - if len(answers) != 1 || answers[0].QuestionID != "q1" || len(answers[0].Selected) != 1 || answers[0].Selected[0] != "Recommended" { - t.Fatalf("answers = %#v, want recommended option", answers) - } + case ask = <-askCh: case <-time.After(2 * time.Second): - t.Fatal("Ask stayed blocked after bypass turned on while queued behind promptMu") + t.Fatal("Ask did not emit AskRequest after acquiring promptMu with bypass on; bypass should not suppress ask") } + // Answer and verify we get the user's choice. + c.AnswerQuestion(ask.ID, []event.AskAnswer{ + {QuestionID: "q1", Selected: []string{"Alternative"}}, + }) + + var answers []event.AskAnswer select { - case <-askRequests: - t.Fatal("bypass must not emit AskRequest after acquiring promptMu") - default: + case answers = <-done: + case <-time.After(2 * time.Second): + t.Fatal("Ask stayed blocked after AnswerQuestion") + } + if len(answers) != 1 || answers[0].QuestionID != "q1" || len(answers[0].Selected) != 1 || answers[0].Selected[0] != "Alternative" { + t.Fatalf("answers = %#v, want Alternative (user's choice, not auto-recommended)", answers) } } From 4e0f7c2a1380a29be273868f9e1dba6c1533489f Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 9 Jun 2026 06:36:34 -0700 Subject: [PATCH 21/64] feat(skill): add read_skill for loading inline skills in plan mode (#3713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_skill is ReadOnly()=false because subagent skills can spawn writer tool calls, so plan mode (read-only) blocks ALL skill use — including inline skills, which only render a body and have no side effects. That made it impossible to consult a playbook while planning (#3491). Add read_skill: a read-only counterpart that renders an inline skill body and rejects subagent skills (pointing the model at run_skill). Being ReadOnly it stays available in plan mode. Added to SubagentMetaTools so spawned agents don't inherit it. Closes #3491 Co-authored-by: reasonix --- internal/agent/task.go | 1 + internal/boot/boot.go | 1 + internal/skill/tools.go | 52 ++++++++++++++++++++++++++++++++++++ internal/skill/tools_test.go | 27 +++++++++++++++++++ 4 files changed, 81 insertions(+) diff --git a/internal/agent/task.go b/internal/agent/task.go index 1f2c06eb8..74373251c 100644 --- a/internal/agent/task.go +++ b/internal/agent/task.go @@ -23,6 +23,7 @@ If you need to ask for clarification, fail with a precise question instead of gu var subagentMetaTools = []string{ "task", "run_skill", + "read_skill", "install_skill", "install_source", "explore", diff --git a/internal/boot/boot.go b/internal/boot/boot.go index 62ddec3c7..3116bfb09 100644 --- a/internal/boot/boot.go +++ b/internal/boot/boot.go @@ -491,6 +491,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { return &event.Profile{Model: model, Effort: effort} } reg.Add(skill.NewRunSkillTool(skillStore, skillRunner, skillProfile)) + reg.Add(skill.NewReadSkillTool(skillStore)) reg.Add(skill.NewInstallSkillTool(skillStore, nil)) reg.Add(installsource.NewTool(installsource.Options{ ProjectRoot: root, diff --git a/internal/skill/tools.go b/internal/skill/tools.go index 7ad0e1dd7..13fed81f2 100644 --- a/internal/skill/tools.go +++ b/internal/skill/tools.go @@ -128,6 +128,58 @@ func (t *runSkillTool) profileForSkill(sk Skill) *event.Profile { return &event.Profile{Model: model, Effort: effort} } +// readSkillTool loads an inline skill body into context without running anything. +type readSkillTool struct { + store *Store +} + +// NewReadSkillTool builds a read-only inline-skill loader. Unlike run_skill it +// stays available in plan mode, so a plan can consult inline playbooks. +func NewReadSkillTool(store *Store) tool.Tool { return &readSkillTool{store: store} } + +func (*readSkillTool) Name() string { return "read_skill" } + +// ReadOnly is true: read_skill only renders an inline skill body (no subagent, +// no side effects), so it is allowed in plan mode where run_skill is not. +func (*readSkillTool) ReadOnly() bool { return true } + +func (*readSkillTool) Description() string { + return "Load an inline playbook from the Skills index into your context WITHOUT running anything — the skill body returns as a tool result you read and follow. Read-only, so it works in plan mode (unlike run_skill). Pass `name` as the BARE identifier (e.g. 'commit'), NOT the `[🧬 subagent]` tag. Subagent-tagged skills are rejected: use run_skill (or the dedicated tool) for those, since they execute work." +} + +func (*readSkillTool) Schema() json.RawMessage { + return json.RawMessage(`{ +"type":"object", +"properties":{ + "name":{"type":"string","description":"Inline skill identifier as it appears in the pinned Skills index. Just the identifier, not the [🧬 subagent] tag."}, + "arguments":{"type":"string","description":"Optional free-form arguments, appended as an 'Arguments:' line; the skill's own instructions decide how to use them."} +}, +"required":["name"] +}`) +} + +func (t *readSkillTool) Execute(_ context.Context, args json.RawMessage) (string, error) { + var p struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } + if err := json.Unmarshal(args, &p); err != nil { + return "", fmt.Errorf("invalid args: %w", err) + } + name := cleanSkillName(p.Name) + if name == "" { + return "", fmt.Errorf("read_skill requires a 'name' argument (got %q, which is just a marker/tag)", p.Name) + } + sk, ok := t.store.Read(name) + if !ok { + return "", fmt.Errorf("unknown skill %q — available: %s", name, availableNames(t.store)) + } + if sk.RunAs == RunSubagent { + return "", fmt.Errorf("read_skill: skill %q is a subagent and must be executed, not read — use run_skill (or the dedicated %s tool)", name, name) + } + return renderInline(sk, strings.TrimSpace(p.Arguments)), nil +} + // --- dedicated subagent wrappers (explore / research / review / security_review) --- type subagentSkillTool struct { diff --git a/internal/skill/tools_test.go b/internal/skill/tools_test.go index af74007df..51e1f967b 100644 --- a/internal/skill/tools_test.go +++ b/internal/skill/tools_test.go @@ -209,3 +209,30 @@ func TestInstallSkill(t *testing.T) { t.Error("install_skill should require a description") } } + +func TestReadSkillLoadsInlineAndIsReadOnly(t *testing.T) { + home := t.TempDir() + writeSkill(t, home, ".reasonix/skills/note.md", "---\ndescription: take a note\n---\nDo the thing.") + tl := NewReadSkillTool(New(Options{HomeDir: home, DisableBuiltins: true})) + + if !tl.ReadOnly() { + t.Fatal("read_skill must be ReadOnly so it works in plan mode") + } + out, err := tl.Execute(context.Background(), json.RawMessage(`{"name":"note","arguments":"with args"}`)) + if err != nil { + t.Fatalf("execute: %v", err) + } + if !strings.Contains(out, "Do the thing.") || !strings.Contains(out, "Arguments: with args") { + t.Errorf("inline body/args missing:\n%s", out) + } +} + +func TestReadSkillRejectsSubagent(t *testing.T) { + home := t.TempDir() + writeSkill(t, home, ".reasonix/skills/dig.md", "---\ndescription: dig\nrunAs: subagent\n---\nbody") + tl := NewReadSkillTool(New(Options{HomeDir: home, DisableBuiltins: true})) + + if _, err := tl.Execute(context.Background(), json.RawMessage(`{"name":"dig"}`)); err == nil || !strings.Contains(err.Error(), "run_skill") { + t.Fatalf("read_skill on a subagent skill should point to run_skill, got %v", err) + } +} From b892ce21cd77eb72fbc3bce4e364acc6a0ad3893 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 9 Jun 2026 06:51:08 -0700 Subject: [PATCH 22/64] fix(config): honor an explicit proxy for no_proxy providers like mimo (#3635) (#3714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(config): honor an explicit proxy for no_proxy providers (mimo) The built-in mimo presets carry no_proxy=true so their domestic endpoint stays off an auto-detected (GFW-circumvention) system proxy. But that bypass was applied in every mode, so behind a mandatory corporate proxy (proxy_mode = "custom") mimo tried a direct connection the firewall blocks — making mimo unusable on enterprise networks (#3635). Apply the provider-level no_proxy bypass only for auto/env proxies. An explicit custom proxy means "route everything through this", so honor it for every provider; a custom-proxy user who still wants a host direct uses network.no_proxy. Closes #3635 * test(config): structurally guard nil-deref (staticcheck SA5011) The release golangci-lint binary CI runs flags SA5011 on guarded t.Fatal derefs in backfill_test.go and migrate_test.go (same cache-masked false positive as #3706); this PR touches the config package so a cold lint run surfaces them. Guard with else-if; no behavior change. --------- Co-authored-by: reasonix --- internal/config/backfill_test.go | 3 +-- internal/config/config.go | 10 ++++++++++ internal/config/migrate_test.go | 3 +-- internal/config/proxy_test.go | 14 ++++++++++++++ 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/internal/config/backfill_test.go b/internal/config/backfill_test.go index bbb8c3af6..a6996d381 100644 --- a/internal/config/backfill_test.go +++ b/internal/config/backfill_test.go @@ -21,8 +21,7 @@ func TestBackfillDeepSeekProRestoresPro(t *testing.T) { pro := hasModel(c, "deepseek-v4-pro") if pro == nil { t.Fatal("deepseek-v4-pro not restored") - } - if pro.Price == nil || pro.Price.Output != 6 { + } else if pro.Price == nil || pro.Price.Output != 6 { t.Errorf("pro price not the preset: %+v", pro.Price) } } diff --git a/internal/config/config.go b/internal/config/config.go index 04e20fdc3..86848bc9b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -271,7 +271,17 @@ func (c *Config) NetworkProxySpec() netclient.ProxySpec { // directProxyHosts collects the base_url hosts of providers marked no_proxy, so // netclient bypasses the proxy for them without knowing any provider by name. +// +// Only for an auto-detected proxy (auto/env): that proxy is typically a +// GFW-circumvention one not meant for domestic endpoints (e.g. mimo), so keep +// them direct. An explicit proxy_mode = "custom" is the user saying "route +// everything through this" — e.g. a mandatory corporate proxy — so honor it for +// every provider; a custom-proxy user who wants a host direct uses +// network.no_proxy instead (#3635). func (c *Config) directProxyHosts() []string { + if c.NetworkProxyMode() == netclient.ModeCustom { + return nil + } seen := map[string]bool{} var out []string for _, p := range c.Providers { diff --git a/internal/config/migrate_test.go b/internal/config/migrate_test.go index 61008544a..d80f5b932 100644 --- a/internal/config/migrate_test.go +++ b/internal/config/migrate_test.go @@ -47,8 +47,7 @@ func TestMigrateImportsKeyPluginsAndLang(t *testing.T) { } if res == nil { t.Fatal("expected a migration result") - } - if !res.KeyToEnv || res.Plugins != 2 { + } else if !res.KeyToEnv || res.Plugins != 2 { t.Errorf("result = %+v, want KeyToEnv=true Plugins=2", res) } diff --git a/internal/config/proxy_test.go b/internal/config/proxy_test.go index ea040ef28..3666c031f 100644 --- a/internal/config/proxy_test.go +++ b/internal/config/proxy_test.go @@ -17,3 +17,17 @@ func TestDirectProxyHostsFromNoProxyProviders(t *testing.T) { t.Errorf("a no_proxy provider's host should land in DirectHosts, got %v", spec.DirectHosts) } } + +func TestExplicitProxyOverridesProviderNoProxy(t *testing.T) { + // An explicit custom proxy (e.g. a mandatory corporate proxy) must apply to + // every provider, including no_proxy ones like mimo, so it isn't unreachable + // behind the proxy (#3635). + c := Default() + c.Network.ProxyMode = "custom" + spec := c.NetworkProxySpec() + for _, h := range spec.DirectHosts { + if h == "token-plan-cn.xiaomimimo.com" { + t.Fatalf("custom proxy must not force mimo direct; DirectHosts = %v", spec.DirectHosts) + } + } +} From 78dd9fa42fae1a793a9b3fb9e3f8361bf6c1d23f Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 9 Jun 2026 07:30:41 -0700 Subject: [PATCH 23/64] ci: suppress staticcheck SA5011 false positives in test files (#3715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pinned golangci-lint binary's staticcheck reports SA5011 (possible nil pointer dereference) on `if x == nil { t.Fatal(...) }`-guarded derefs in tests — it doesn't model t.Fatal as terminating. The same code is clean under a locally-built golangci-lint, and the finding is masked by the action cache until a go.sum change cold-busts it, so it surfaces per-package and has failed lint on otherwise-correct PRs (handled ad hoc in #3706, #3714). Scope a test-only SA5011 exclusion so it stops blocking PRs while SA5011 keeps guarding production code. Co-authored-by: reasonix --- .golangci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.golangci.yml b/.golangci.yml index 979d92e15..fcfdb9a6d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -33,6 +33,15 @@ linters: - path: _test\.go linters: - errcheck + # The pinned golangci-lint binary's staticcheck reports SA5011 false + # positives on `if x == nil { t.Fatal(...) }`-guarded derefs in tests (it + # doesn't treat t.Fatal as terminating); the same code is clean under a + # locally-built golangci-lint. Scope the suppression to tests so SA5011 + # still guards production code. + - path: _test\.go + linters: + - staticcheck + text: SA5011 issues: max-issues-per-linter: 0 From a8c785842cbbb82bc9da689554c2d4c399144d36 Mon Sep 17 00:00:00 2001 From: HorusEyes Date: Tue, 9 Jun 2026 23:12:26 +0800 Subject: [PATCH 24/64] fix: reload persist rule into in-memory Policy immediately (#3716) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: always allow (persist) now grants all tools for the session When user clicks 'Always allow' (Allow Persistently) on a tool approval prompt, the current code only remembers the grant for that specific tool (e.g. write_file). Later in the same session, other tools like bash still trigger permission prompts, confusing the user. This fix makes 'Always allow' set a wildcard session grant (c.granted['*']) so all writer tools are auto-allowed for the rest of the session without further prompting. The actual on-disk config rule is still written as before via OnRemember for cross-session persistence. The normal 'Allow for this session' remains tool-specific as before. * Revert "fix: always allow (persist) now grants all tools for the session" This reverts commit f90ba6f58705476dfab05a095e3bc1db924f813f. * fix: reload persist rule into in-memory Policy immediately After OnRemember writes an 'always allow' rule to the on-disk config, also append the parsed rule to the Gate's in-memory Policy.Allow slice so it takes effect in the current session without requiring a restart. Previously, clicking 'Always allow' on a tool (e.g. write_file) would: 1. Write the rule to reasonix.toml ✅ 2. Set c.granted['write_file'] = true for the Approver path ✅ 3. BUT: the Gate's Policy was not updated in memory ❌ → Any code path consulting Policy.Decide() directly would still see the old policy and not match the new allow rule This fix adds the parsed rule to g.Policy.Allow after writing to disk, so the in-memory Policy stays consistent with the persisted config. Ref: #3607 --------- Co-authored-by: HorusJiang --- internal/permission/permission.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/permission/permission.go b/internal/permission/permission.go index 61f162580..5b49b3b18 100644 --- a/internal/permission/permission.go +++ b/internal/permission/permission.go @@ -340,6 +340,14 @@ func (g *Gate) Check(ctx context.Context, toolName string, args json.RawMessage, // later subject (a different file / command) is allowed without // re-prompting. Deny rules still take precedence on every call. g.OnRemember(toolName) + // Also add the rule to the in-memory Policy immediately so it + // takes effect in the current session without requiring a restart. + // The session-level grant (controller.granted) already covers the + // Approver path, but any code path that consults Policy.Decide() + // directly would miss the rule until the next controller build. + if rule, ok := ParseRule(toolName); ok { + g.Policy.Allow = append(g.Policy.Allow, rule) + } } return true, "", nil default: From cb92a254f71c769d2dfe1143fafad28e7892b9f3 Mon Sep 17 00:00:00 2001 From: paradoxSCH <64133628+paradoxSCH@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:15:09 +0800 Subject: [PATCH 25/64] fix(serve): restore query helper (#3658) --- internal/serve/index.html | 1 + internal/serve/serve_test.go | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/internal/serve/index.html b/internal/serve/index.html index dde7b345b..7852f5901 100644 --- a/internal/serve/index.html +++ b/internal/serve/index.html @@ -639,6 +639,7 @@ +const $ = s => document.querySelector(s); const $$ = s => document.querySelectorAll(s); const log = $('#log'), input = $('#in'), btnSend = $('#btn-send'), btnStop = $('#btn-stop'); const statusDot = $('#status-dot-footer'), statusText = $('#status-text'); diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go index 5fde1efc8..8fa789bf1 100644 --- a/internal/serve/serve_test.go +++ b/internal/serve/serve_test.go @@ -208,6 +208,18 @@ func TestServeIndexPage(t *testing.T) { } } +func TestServeIndexDefinesQueryHelpers(t *testing.T) { + html := string(indexHTML) + for _, want := range []string{ + "const $ = s => document.querySelector(s);", + "const $$ = s => document.querySelectorAll(s);", + } { + if !strings.Contains(html, want) { + t.Fatalf("serve index missing query helper %q", want) + } + } +} + func TestServeIndexPagePassesLanguagePreferenceToClient(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) From 88d46c4056e09737b9ad4e50ad63538a20c38e03 Mon Sep 17 00:00:00 2001 From: CVEngineer <129239603+CVEngineer66@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:19:17 +0800 Subject: [PATCH 26/64] fix(dev): suppress pnpm TTY prompts and lockfile age checks in dev script (#3701) * fix(dev): suppress pnpm TTY prompts and lockfile age checks in dev script pnpm v11 aborts node_modules removal without a TTY and rejects lockfiles with packages published within the minimum release age window. Both checks are meant for CI safety and are irrelevant during local development. Set PNPM_CONFIG_CONFIRM_MODULES_PURGE=false and PNPM_CONFIG_MINIMUM_RELEASE_AGE=0 before launching wails dev. * ci: trigger re-run --------- Co-authored-by: wufengfan --- dev | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dev b/dev index 87e93cf3b..8d3704cd6 100755 --- a/dev +++ b/dev @@ -29,6 +29,9 @@ wails_port="${REASONIX_DESKTOP_WAILS_PORT:-$((vite_port + 29000))}" export REASONIX_DESKTOP_VITE_PORT="$vite_port" export REASONIX_DEV=1 +# pnpm v11: suppress TTY prompts and skip lockfile age checks in headless mode. +export PNPM_CONFIG_CONFIRM_MODULES_PURGE=false +export PNPM_CONFIG_MINIMUM_RELEASE_AGE=0 echo "Reasonix desktop dev" echo " worktree: $root" From 1658230355c22e9ef1ea6579b031b1f4dfdbe798 Mon Sep 17 00:00:00 2001 From: CnsMaple <92523839+CnsMaple@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:23:11 +0800 Subject: [PATCH 27/64] fix(setup): align custom provider base URL between wizard probe and chat client (#3669) Wizard now probes the same candidate URLs (root + /v1 + compat suffixes) the chat client resolves, and the anthropic provider strips a trailing /v1 from base_url so a pasted OpenAI-shape URL no longer produces /v1/v1/messages. --- internal/cli/cli.go | 41 ++++++++- internal/cli/cli_test.go | 85 +++++++++++++++++++ internal/provider/anthropic/anthropic.go | 17 +++- internal/provider/anthropic/anthropic_test.go | 40 +++++++++ 4 files changed, 180 insertions(+), 3 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index f45c2f87f..938d2b2ce 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -12,6 +12,7 @@ import ( "flag" "fmt" "io" + "log/slog" "net/url" "os" "os/signal" @@ -944,6 +945,42 @@ func fetchOrFallback(probe *config.ProviderEntry, famName string) []string { return models } +// fetchModelListCompat walks the full set of model-list URL candidates a given +// base URL can resolve to (root, /v1, known OpenAI/Anthropic compat suffixes) +// and returns the first successful fetch. This is the wizard-time probe for a +// *user-supplied* custom provider — its baseURL is whatever the user pasted, +// and "whatever they pasted" might be https://x.com (root, probe /v1/models) +// or https://x.com/v1 (versioned, probe /v1/models directly). Previously the +// wizard hardcoded `baseURL + "/models"`, which works for OpenAI-shape URLs +// but silently fails for Anthropic-shape roots and the reverse — so the +// wizard's idea of "what models exist" diverged from the chat client's actual +// endpoint. Returning the empty slice (not an error) on full miss lets the +// wizard fall through to a manual text input without an error message. +func fetchModelListCompat(ctx context.Context, baseURL, apiKey string) ([]string, error) { + candidates, err := config.BuildModelFetchURLs(baseURL, "") + if err != nil { + return nil, err + } + var lastErr error + for _, u := range candidates { + models, err := openai.FetchModels(ctx, u, apiKey) + if err == nil { + return models, nil + } + lastErr = err + // An endpoint-miss is not a hard error — try the next candidate. + // Anything else (auth, 5xx, bad TLS) bubbles up immediately because + // retrying it on a sibling URL won't help. + if !openai.IsModelFetchEndpointMiss(err) { + return nil, err + } + } + if lastErr != nil { + slog.Debug("model-list probe: all candidates missed", "base_url", baseURL, "err", lastErr) + } + return nil, nil +} + // buildFamilyEntry returns a single ProviderEntry exposing the user's // selected models under one entry. It preserves the preset's API key env, // base URL, kind, context window, pricing, and effort — the things that @@ -1159,7 +1196,7 @@ func promptCustomProviderFromURL() ([]config.ProviderEntry, error) { fmt.Printf(" %s\n", dim(fmt.Sprintf(i18n.M.FetchingModelsFmt, "custom"))) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - models, err := openai.FetchModels(ctx, baseURL+"/models", apiKey) + models, err := fetchModelListCompat(ctx, baseURL, apiKey) if err != nil || len(models) == 0 { if err != nil { fmt.Fprintf(os.Stderr, " %s\n", dim(fmt.Sprintf(i18n.M.FetchModelsFailedFmt, "custom", err))) @@ -1265,7 +1302,7 @@ func promptAnthropicProviderFromURL() ([]config.ProviderEntry, error) { fmt.Printf(" %s\n", dim(fmt.Sprintf(i18n.M.AnthropicFetchingModelsFmt, "anthropic"))) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - models, err := openai.FetchModels(ctx, baseURL+"/models", apiKey) + models, err := fetchModelListCompat(ctx, baseURL, apiKey) if err != nil || len(models) == 0 { if err != nil { fmt.Fprintf(os.Stderr, " %s\n", dim(fmt.Sprintf(i18n.M.AnthropicFetchModelsFailedFmt, "anthropic", err))) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 5f76b661c..79de4eb26 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -3,12 +3,16 @@ package cli import ( "bufio" "bytes" + "context" "errors" "io" + "net/http" + "net/http/httptest" "os" "path/filepath" "reflect" "strings" + "sync/atomic" "testing" "reasonix/internal/config" @@ -498,6 +502,87 @@ func TestFetchOrFallback(t *testing.T) { }) } +// TestFetchModelListCompatWalksCandidates covers the wizard's custom-provider +// model probe. Previously the probe was a single URL (baseURL+"/models"), +// which worked for OpenAI vendors with a /v1 base URL but silently failed +// for Anthropic-style root URLs (no /v1) and Anthropic-compatible proxies +// (a /v1 base URL but a /v1/messages endpoint). The new helper walks +// BuildModelFetchURLs's candidate list — root + /v1 + known compat +// suffixes — so the same probe now succeeds for both shapes, matching +// what the conversation-time client URL will actually be. +func TestFetchModelListCompatWalksCandidates(t *testing.T) { + t.Run("anthropic root form resolves via v1 fallback", func(t *testing.T) { + var gotPath atomic.Value + gotPath.Store("") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath.Store(r.URL.Path) + if r.URL.Path == "/v1/models" { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":[{"id":"claude-test"}]}`) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + models, err := fetchModelListCompat(context.Background(), srv.URL, "k") + if err != nil { + t.Fatalf("fetchModelListCompat: %v", err) + } + if !reflect.DeepEqual(models, []string{"claude-test"}) { + t.Errorf("models = %v, want [claude-test]", models) + } + if got := gotPath.Load().(string); got != "/v1/models" { + t.Errorf("probe path = %q, want /v1/models (root form should fall through to v1 candidate)", got) + } + }) + + t.Run("versioned v1 base URL hits models directly", func(t *testing.T) { + var gotPath atomic.Value + gotPath.Store("") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath.Store(r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":[{"id":"model-a"}]}`) + })) + defer srv.Close() + + models, err := fetchModelListCompat(context.Background(), srv.URL+"/v1", "k") + if err != nil { + t.Fatalf("fetchModelListCompat: %v", err) + } + if !reflect.DeepEqual(models, []string{"model-a"}) { + t.Errorf("models = %v, want [model-a]", models) + } + if got := gotPath.Load().(string); got != "/v1/models" { + t.Errorf("probe path = %q, want /v1/models", got) + } + }) + + t.Run("endpoint-miss on every candidate returns empty (manual flow)", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + models, err := fetchModelListCompat(context.Background(), srv.URL, "k") + if err != nil { + t.Fatalf("expected graceful empty result on all-miss, got err: %v", err) + } + if len(models) != 0 { + t.Errorf("expected empty models on all-miss, got %v", models) + } + }) + + t.Run("non-404 network error short-circuits with the real error", func(t *testing.T) { + // Point at a closed port — connection refused, not a 404. + models, err := fetchModelListCompat(context.Background(), "http://127.0.0.1:1", "k") + if err == nil { + t.Fatalf("expected error for unreachable host, got models=%v", models) + } + }) +} + // TestFamilyStaticModels proves the offline fallback unions every member of a // family (the flash + pro SKUs), not just the first — the regression that left // users with only flash when the live /models probe failed. diff --git a/internal/provider/anthropic/anthropic.go b/internal/provider/anthropic/anthropic.go index 9d6fe141e..b7fe900e3 100644 --- a/internal/provider/anthropic/anthropic.go +++ b/internal/provider/anthropic/anthropic.go @@ -67,11 +67,26 @@ func New(cfg provider.Config) (provider.Provider, error) { if err != nil { return nil, fmt.Errorf("anthropic: network: %w", err) } + // Anthropic's API surface is at {root}/v1/messages, so c.baseURL stores + // the *root* — without any trailing /v1. The setup wizard, however, lets + // users paste a full OpenAI-compatible URL (e.g. + // "https://proxy.example.com/v1") because that's what /models probes + // expect. Stripping the trailing /v1 here makes both forms land on the + // same endpoint without forcing users to remember Anthropic's quirky + // root-vs-versioned split. Without this, a user pasting + // "https://proxy.example.com/v1" would probe /v1/models successfully + // but get the chat client concatenating onto + // "https://proxy.example.com/v1/v1/messages" — a 404. + root := strings.TrimRight(baseURL, "/") + root = strings.TrimSuffix(root, "/v1") + if root == "" { + root = defaultBaseURL + } return &client{ name: name, apiKey: cfg.APIKey, keyEnv: keyEnv, - baseURL: strings.TrimRight(baseURL, "/"), + baseURL: root, model: cfg.Model, thinking: thinking, effort: effort, diff --git a/internal/provider/anthropic/anthropic_test.go b/internal/provider/anthropic/anthropic_test.go index cfb99d49d..f19ddb663 100644 --- a/internal/provider/anthropic/anthropic_test.go +++ b/internal/provider/anthropic/anthropic_test.go @@ -326,6 +326,46 @@ func TestReadStreamThinking(t *testing.T) { } } +// TestBaseURLNormalizedForV1Messages checks the URL-rewriting step in New(). +// Anthropic's Messages endpoint is {root}/v1/messages, but the setup wizard +// accepts OpenAI-style URLs (e.g. "https://proxy.example.com/v1") because +// /models probes expect that shape. Without the strip, the chat client would +// concatenate /v1/messages onto an already-versioned root and the request +// would go to https://proxy.example.com/v1/v1/messages — failing 404. +func TestBaseURLNormalizedForV1Messages(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"plain root (no /v1)", "https://api.anthropic.com", "https://api.anthropic.com"}, + {"versioned v1 (OpenAI shape)", "https://proxy.example.com/v1", "https://proxy.example.com"}, + {"versioned v1 with trailing slash", "https://proxy.example.com/v1/", "https://proxy.example.com"}, + {"versioned v1 with path prefix", "https://gateway.example.com/api/v1", "https://gateway.example.com/api"}, + {"trailing slash only", "https://api.anthropic.com/", "https://api.anthropic.com"}, + {"empty falls back to default", "", "https://api.anthropic.com"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p, err := New(provider.Config{ + Name: "test", + Model: "claude-opus-4-8", + BaseURL: tc.in, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + c, ok := p.(*client) + if !ok { + t.Fatalf("provider type = %T, want *client", p) + } + if c.baseURL != tc.want { + t.Errorf("baseURL = %q, want %q", c.baseURL, tc.want) + } + }) + } +} + // Ensure the package wires into the registry under the expected kind. func TestRegistered(t *testing.T) { p, err := provider.New("anthropic", provider.Config{Model: "claude-opus-4-8", Name: "claude"}) From 89aca795f6dbd9e3a61ea192bd461ce8b812a9c5 Mon Sep 17 00:00:00 2001 From: CnsMaple <92523839+CnsMaple@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:25:44 +0800 Subject: [PATCH 28/64] fix(model): persist /model selection to user config.toml (#3671) C:/Program Files/Git/model now writes the chosen provider/model to default_model in the user config so the next session starts on it instead of the global default. SetDefaultModel accepts the provider/model form (validated via ResolveModel). --- internal/cli/model.go | 33 +++++++++++++++++++++++++-- internal/cli/model_test.go | 43 ++++++++++++++++++++++++++++++++++++ internal/config/edit.go | 18 ++++++++++----- internal/config/edit_test.go | 15 +++++++++++++ 4 files changed, 102 insertions(+), 7 deletions(-) diff --git a/internal/cli/model.go b/internal/cli/model.go index e2c140792..6d36bcd45 100644 --- a/internal/cli/model.go +++ b/internal/cli/model.go @@ -2,7 +2,6 @@ package cli import ( "fmt" - "log/slog" tea "charm.land/bubbletea/v2" @@ -33,10 +32,15 @@ func (m *chatTUI) runModelSubcommand(input string) { m.notice(fmt.Sprintf(i18n.M.ModelAlreadyOnFmt, ref)) return } + // Persist the user's choice to ~/.config/reasonix/config.toml so the next + // session starts on the same model instead of falling back to the global + // default. Mirrors the pattern used by /theme (persistTheme), /effort, and + // /language. + m.persistModel(ref) carried := m.ctrl.History() prevPath := m.ctrl.SessionPath() if err := m.ctrl.Snapshot(); err != nil { - slog.Warn("model switch: snapshot failed", "err", err) + m.notice("model: snapshot failed: " + err.Error()) } m.notice(fmt.Sprintf(i18n.M.ModelSwitchingFmt, ref)) @@ -94,6 +98,31 @@ func (m *chatTUI) showModels() { m.commitLine(renderModels(m.width, refs, m.modelRef)) } +// persistModel writes ref (a "provider/model" string) to default_model in +// ~/.config/reasonix/config.toml so the next CLI launch starts on the same +// model. The in-memory switch is always allowed to proceed regardless of the +// outcome here, but every step (rejected by validation, save failed, or +// persisted successfully) reports back to the TUI notice channel so the user +// can see whether their /model choice will survive a restart. Run before +// Snapshot/ModelSwitchingFmt so the persistence outcome shows up first in +// the notice area. +func (m *chatTUI) persistModel(ref string) { + path := config.UserConfigPath() + if path == "" { + return + } + edit := config.LoadForEdit(path) + if err := edit.SetDefaultModel(ref); err != nil { + m.notice(fmt.Sprintf("model: persist refused: %v (ref=%s)", err, ref)) + return + } + if err := edit.SaveTo(path); err != nil { + m.notice(fmt.Sprintf("model: persist save failed: %v (ref=%s, path=%s)", err, ref, path)) + return + } + m.notice(fmt.Sprintf("model: persisted (ref=%s, path=%s)", ref, path)) +} + // modelRefs returns the configured provider/model refs for slash completion. func modelRefs() []string { cfg, err := config.Load() diff --git a/internal/cli/model_test.go b/internal/cli/model_test.go index 6811845b5..c06a0fc3d 100644 --- a/internal/cli/model_test.go +++ b/internal/cli/model_test.go @@ -1,8 +1,11 @@ package cli import ( + "os" "strings" "testing" + + "reasonix/internal/config" ) // TestModelRefsFromConfig verifies the /model picker enumerates configured @@ -49,3 +52,43 @@ func TestModelArgCompletion(t *testing.T) { t.Fatalf("/model arg completion should offer refs, ok=%v n=%d", ok, len(items)) } } + +// TestPersistModelWritesDefaultModel verifies that calling persistModel with a +// "provider/model" ref writes default_model = "" to the user config file +// in TOML form. This is the fix for the "default model resets on every launch" +// regression: previously /model only mutated the in-memory controller and the +// next startup read the global default. +func TestPersistModelWritesDefaultModel(t *testing.T) { + isolateUserConfig(t) + t.Setenv("DEEPSEEK_API_KEY", "test-key") + t.Setenv("MIMO_API_KEY", "") + + m := newTestChatTUI() + m.persistModel("deepseek-flash/deepseek-v4-flash") + + body, err := os.ReadFile(config.UserConfigPath()) + if err != nil { + t.Fatalf("read saved config: %v", err) + } + if !strings.Contains(string(body), `default_model = "deepseek-flash/deepseek-v4-flash"`) { + t.Fatalf("saved config missing default_model ref:\n%s", body) + } +} + +// TestPersistModelRejectsUnknownRef verifies that an unresolvable ref is +// silently dropped (logged to slog, not pushed to the TUI notice channel) +// and never lands in the config file. Reason: surface a "persist failed" +// notice on the input box would make /model feel broken to users whose +// stored config doesn't list the exact model ref they picked; the in- +// memory switch still goes through. +func TestPersistModelRejectsUnknownRef(t *testing.T) { + isolateUserConfig(t) + t.Setenv("DEEPSEEK_API_KEY", "test-key") + + m := newTestChatTUI() + m.persistModel("ghost/never-existed") + + if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) { + t.Fatalf("unknown ref must not create config file, stat err=%v", err) + } +} diff --git a/internal/config/edit.go b/internal/config/edit.go index a4742ed38..08df216ad 100644 --- a/internal/config/edit.go +++ b/internal/config/edit.go @@ -29,12 +29,20 @@ const ( listDeny = "deny" ) -// SetDefaultModel points default_model at an existing provider. It errors if no -// provider by that name is configured, so a UI can't strand the config on a -// model that doesn't exist. +// SetDefaultModel points default_model at an existing model. It accepts both +// forms used by the runtime resolver: +// - "provider" — the provider's own default model; +// - "provider/model" — that specific model under that provider. +// +// Either is rejected when the target does not exist, so a UI can't strand +// the config on a model that doesn't exist. func (c *Config) SetDefaultModel(name string) error { - if _, ok := c.Provider(name); !ok { - return fmt.Errorf("set default: no provider %q (configured: %s)", name, c.providerNames()) + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("set default: empty name") + } + if _, ok := c.ResolveModel(name); !ok { + return fmt.Errorf("set default: no such model %q (configured: %s)", name, c.providerNames()) } c.DefaultModel = name return nil diff --git a/internal/config/edit_test.go b/internal/config/edit_test.go index 0b3778250..6b278a755 100644 --- a/internal/config/edit_test.go +++ b/internal/config/edit_test.go @@ -20,6 +20,21 @@ func TestSetDefaultModel(t *testing.T) { if err := c.SetDefaultModel("nope"); err == nil { t.Error("expected error for unknown provider") } + // "provider/model" form is also accepted: the /model picker stores the + // full ref so a user can land on a non-default model under the same + // provider across restarts. + if err := c.SetDefaultModel("mimo-pro/mimo-v2.5-pro"); err != nil { + t.Fatalf("set provider/model default: %v", err) + } + if c.DefaultModel != "mimo-pro/mimo-v2.5-pro" { + t.Errorf("default = %q, want mimo-pro/mimo-v2.5-pro", c.DefaultModel) + } + if err := c.SetDefaultModel("mimo-pro/missing"); err == nil { + t.Error("expected error for unknown model under known provider") + } + if err := c.SetDefaultModel(""); err == nil { + t.Error("expected error for empty name") + } } func TestUIThemeNormalizes(t *testing.T) { From da31fd4c60cd55eeedb3670b0805c617ab12ecce Mon Sep 17 00:00:00 2001 From: wade1999 Date: Tue, 9 Jun 2026 23:28:35 +0800 Subject: [PATCH 29/64] fix(desktop): keep Windows tray loop on locked OS thread (#3717) Wrap the Windows systray loop in runtime.LockOSThread() so fyne.io/systray's Win32 hidden-window message loop stays pinned to one OS thread; the scheduler migrating the goroutine left the tray unresponsive while the app was in the tray. Fixes #3516. --- desktop/tray_loop_windows.go | 16 ++++++++++++++-- desktop/tray_loop_windows_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 desktop/tray_loop_windows_test.go diff --git a/desktop/tray_loop_windows.go b/desktop/tray_loop_windows.go index 6721011db..b48bd7fd5 100644 --- a/desktop/tray_loop_windows.go +++ b/desktop/tray_loop_windows.go @@ -2,9 +2,21 @@ package main -import "fyne.io/systray" +import ( + "runtime" + + "fyne.io/systray" +) func startDesktopTray(onReady, onExit func()) func() { - go systray.Run(onReady, onExit) + go runDesktopTrayLoop(func() { + systray.Run(onReady, onExit) + }) return systray.Quit } + +func runDesktopTrayLoop(run func()) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + run() +} diff --git a/desktop/tray_loop_windows_test.go b/desktop/tray_loop_windows_test.go new file mode 100644 index 000000000..8924ae947 --- /dev/null +++ b/desktop/tray_loop_windows_test.go @@ -0,0 +1,30 @@ +//go:build windows + +package main + +import ( + "runtime" + "testing" + + "golang.org/x/sys/windows" +) + +func TestDesktopTrayLoopRunsOnLockedOSThread(t *testing.T) { + done := make(chan struct{}) + runDesktopTrayLoop(func() { + first := windows.GetCurrentThreadId() + for i := 0; i < 100; i++ { + runtime.Gosched() + } + if got := windows.GetCurrentThreadId(); got != first { + t.Fatalf("tray loop moved OS threads: first=%d got=%d", first, got) + } + close(done) + }) + + select { + case <-done: + default: + t.Fatal("tray loop did not run") + } +} From 32d3c3e353ed79666d2f62fe613299f339939a4e Mon Sep 17 00:00:00 2001 From: 55 <38285521+lizhengwu@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:49:08 +0800 Subject: [PATCH 30/64] fix(tui): suspend cleanly on Ctrl+Z (#3697) Map Ctrl+Z to Bubble Tea's tea.Suspend so an explicit user suspend releases terminal state before stopping. First mitigation for #3655; the Unix SIGTTIN job-control guard is tracked separately. --- internal/cli/chat_tui.go | 2 ++ internal/cli/chat_tui_test.go | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/internal/cli/chat_tui.go b/internal/cli/chat_tui.go index af39bf6f9..51216a924 100644 --- a/internal/cli/chat_tui.go +++ b/internal/cli/chat_tui.go @@ -806,6 +806,8 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) { case "pgdown": m.viewport.PageDown() return m, finalize(m, cmds) + case "ctrl+z": + return m, tea.Suspend } // A question card is modal: keys drive it. In its free-text ("Type // something") mode, the keystroke goes to the textarea — Enter confirms the diff --git a/internal/cli/chat_tui_test.go b/internal/cli/chat_tui_test.go index 0050bcf1e..29fc7387b 100644 --- a/internal/cli/chat_tui_test.go +++ b/internal/cli/chat_tui_test.go @@ -1288,6 +1288,19 @@ func TestDoubleCtrlCQuit(t *testing.T) { } } +func TestCtrlZSendsSuspend(t *testing.T) { + m := newTestChatTUI() + ctrlZ := tea.KeyPressMsg{Code: 'z', Mod: tea.ModCtrl} + + _, cmd := m.Update(ctrlZ) + if cmd == nil { + t.Fatal("expected Ctrl+Z to return a suspend command") + } + if msg := cmd(); msg != (tea.SuspendMsg{}) { + t.Fatalf("expected tea.SuspendMsg, got %T", msg) + } +} + // TestCtrlCClearsInput verifies that a single Ctrl+C while idle with non-empty // input clears the composer without arming the double-press quit gesture. func TestCtrlCClearsInput(t *testing.T) { From 11a13780d32b5357bbb80aa46bb50efaed9e999e Mon Sep 17 00:00:00 2001 From: CnsMaple <92523839+CnsMaple@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:52:25 +0800 Subject: [PATCH 31/64] fix(tui): keep plan mode on Esc; Ctrl+C copy beats clear (#3670) Complete #3051: drop the plan-mode arm from the Esc handler so mode switches are exclusively Shift+Tab driven (Esc must not silently drop to a less-permissive mode). Also hoist Ctrl+C selection-copy above clear-input so copying a selection no longer wipes a half-typed draft. --- internal/cli/chat_tui.go | 46 +++++++++++++++------------ internal/cli/chat_tui_test.go | 59 +++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 20 deletions(-) diff --git a/internal/cli/chat_tui.go b/internal/cli/chat_tui.go index 51216a924..56fc6a804 100644 --- a/internal/cli/chat_tui.go +++ b/internal/cli/chat_tui.go @@ -923,10 +923,13 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": // "Back out" of the most specific in-progress state: un-send a just-sent - // turn (server not yet replied), cancel a streaming turn, turn plan mode - // off, or clear typed-but-unsent input. YOLO mode is only exited via - // Shift+Tab cycle (/plan → YOLO → normal) or --yolo flag. Scrollback is - // the terminal's now, so there's no viewport to dismiss. + // turn (server not yet replied), cancel a streaming turn, or clear + // typed-but-unsent input. Mode switches (normal/plan/YOLO) are + // exclusively driven by Shift+Tab — Esc must not silently flip a + // session from plan or YOLO back to a less-permissive mode. PR #3051 + // removed the YOLO half of this; plan mode was missed and is fixed + // here. Scrollback is the terminal's now, so there's no viewport to + // dismiss. switch { case m.state == tuiRunning && m.bubblePending: m.unsendPending() @@ -939,13 +942,10 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = tuiIdle m.confirmBubbleSent() } - case m.planMode: - m.planMode = false - m.ctrl.SetPlanMode(false) default: - // Idle with nothing to back out: a double-Esc on an empty composer - // opens the rewind picker (Claude Code's gesture); a first Esc just - // arms it. Non-empty input clears as before. + // Idle (any mode): a double-Esc on an empty composer opens the + // rewind picker (Claude Code's gesture); a first Esc just arms + // it. Non-empty input clears as before. if strings.TrimSpace(m.input.Value()) == "" { if !m.lastEsc.IsZero() && time.Since(m.lastEsc) < 600*time.Millisecond { m.lastEsc = time.Time{} @@ -968,22 +968,28 @@ func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } - // Idle: if the composer has text, a single press clears it (like Esc). - // On an empty composer: if there's an active text selection, copy to - // clipboard (standard terminal convention); otherwise require double-press - // within 1.5s to quit. - if strings.TrimSpace(m.input.Value()) != "" { - m.input.Reset() - m.pastedBlocks = nil - m.lastCtrlCAt = time.Time{} - return m, nil - } + // Idle: an active text selection takes precedence over the + // composer-clear / double-press-quit gestures. Standard terminal + // convention is "Ctrl+C copies the selection" — the user can still + // clear the input with a second Ctrl+C once the selection is gone. + // Hoisting this branch above the clear branch also stops the + // previous behaviour where Ctrl+C would dismiss a selection AND + // wipe any draft text the user was typing — felt like the + // selection was being silently lost. if sel.active && !sel.empty() { m.sel = sel // restore so selectedText() can read it text := m.selectedText() m.sel = selection{} return m, tea.Batch(copyToClipboard(text), finalize(m, cmds)) } + // No selection: if the composer has text, a single press clears it + // (like Esc); on an empty composer a double-press within 1.5s quits. + if strings.TrimSpace(m.input.Value()) != "" { + m.input.Reset() + m.pastedBlocks = nil + m.lastCtrlCAt = time.Time{} + return m, nil + } if !m.lastCtrlCAt.IsZero() && time.Since(m.lastCtrlCAt) < 1500*time.Millisecond { return m, tea.Quit } diff --git a/internal/cli/chat_tui_test.go b/internal/cli/chat_tui_test.go index 29fc7387b..1cc39170c 100644 --- a/internal/cli/chat_tui_test.go +++ b/internal/cli/chat_tui_test.go @@ -1470,3 +1470,62 @@ func TestTruncateSubject(t *testing.T) { }) } } + +// TestCtrlCCopyBeatsClearInput — regression for the bug where an active +// selection AND a non-empty composer both existed: Ctrl+C used to wipe the +// draft text and discard the selection. The fix hoists the selection-copy +// branch above the clear-input branch so the user's draft survives. After +// the copy the user can still press Ctrl+C again to clear the composer. +func TestCtrlCCopyBeatsClearInput(t *testing.T) { + var copied string + clipboardWriteAll = func(text string) error { copied = text; return nil } + defer func() { clipboardWriteAll = clipboard.WriteAll }() + + m := newTestChatTUI() + m.input.SetValue("draft I'm typing") // non-empty composer + m.transcript = []string{"selected text"} + m.wrappedLines = []string{"selected text"} + m.sel = selection{active: true, anchor: selPos{line: 0, col: 0}, head: selPos{line: 0, col: 8}} + + ctrlC := tea.KeyPressMsg{Code: 'c', Mod: 4} + out, cmd := m.Update(ctrlC) + m2 := out.(chatTUI) + + // Draft text must survive the selection copy. + if got := m2.input.Value(); got != "draft I'm typing" { + t.Errorf("composer draft wiped by Ctrl+C copy; got %q, want preserved", got) + } + if cmd == nil { + t.Fatal("expected clipboard cmd") + } + cmd() + if copied != "selected" { + t.Errorf("clipboard = %q, want %q", copied, "selected") + } + + // Second Ctrl+C (no selection, non-empty composer) clears the draft. + out2, _ := m2.Update(ctrlC) + m3 := out2.(chatTUI) + if got := m3.input.Value(); got != "" { + t.Errorf("second Ctrl+C should clear composer; got %q", got) + } +} + +// TestEscInPlanModeDoesNotExitPlan — regression for the part of PR #3051 that +// was missed: Esc was still falling into the case m.planMode branch. The +// Shift+Tab cycle is the only path that flips plan mode; Esc must only +// rewind / clear input. PR #3051 already removed the equivalent YOLO branch; +// the m.ctrl.SetBypass path is exercised end-to-end in control/yolo_test.go +// and intentionally not duplicated here. +func TestEscInPlanModeDoesNotExitPlan(t *testing.T) { + m := newTestChatTUI() + m.planMode = true + + esc := tea.KeyPressMsg{Code: tea.KeyEsc} + out, _ := m.Update(esc) + m2 := out.(chatTUI) + + if !m2.planMode { + t.Error("Esc must not exit plan mode; only Shift+Tab should") + } +} From d9bbf77929bc140abd383e32394de18d11686783 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 9 Jun 2026 08:58:48 -0700 Subject: [PATCH 32/64] test: de-flake two windows-latest timing tests (#3726) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(plugin): de-flake TestStartPhaseAReturnsBeforePhaseB The test asserted StartAvailable returns in <150ms while the helper stalls prompts/list by 200ms — a wall-clock proxy for "phase A doesn't block on the prompt fetch". But StartAvailable spawns a subprocess and runs the MCP initialize handshake, and that cost alone can approach or exceed 150ms on a slow CI runner (observed 196ms on windows-latest), so the threshold flaked with no real regression. Drop the timing assertion and keep the deterministic deferral checks that already encode the contract: after phase A the prompts surface is empty, and prompts only materialise after StartPhaseB drains them. A realistic "phase A surfaces prompts inline" regression still trips the empty-surface check; the removed threshold only proxied an implausible fetch-but-not-surface case at the cost of flakiness. * test(control): de-flake TestRunShell_CancelStopsCommand The test waited 5s for TurnDone after Cancel, but cmd.Wait honours shellWaitDelay (also 5s) — and on Windows cmd.Cancel spawns taskkill /F /T — so when the kill rides the full grace, TurnDone arrives at ~shellWaitDelay and the 5s budget loses its own race (observed 5.18s on windows-latest). Wait shellWaitDelay + 10s instead, via a new waitForDoneWithin helper that the existing waitForDone now delegates to. The command is still killed promptly in practice; the larger budget only removes the dead heat with the grace period. --- internal/control/shell_test.go | 14 ++++++++++++-- internal/plugin/plugin_test.go | 12 ++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/internal/control/shell_test.go b/internal/control/shell_test.go index 118b26e16..da14af8a9 100644 --- a/internal/control/shell_test.go +++ b/internal/control/shell_test.go @@ -25,11 +25,16 @@ func collectSink() (event.Sink, chan event.Event, *[]event.Event) { } func waitForDone(t *testing.T, done chan event.Event) event.Event { + t.Helper() + return waitForDoneWithin(t, done, 5*time.Second) +} + +func waitForDoneWithin(t *testing.T, done chan event.Event, d time.Duration) event.Event { t.Helper() select { case e := <-done: return e - case <-time.After(5 * time.Second): + case <-time.After(d): t.Fatal("timed out waiting for TurnDone") return event.Event{} } @@ -152,7 +157,12 @@ func TestRunShell_CancelStopsCommand(t *testing.T) { time.Sleep(100 * time.Millisecond) ctrl.Cancel() - e := waitForDone(t, done) + // Cancel kills the shell via the run context, but cmd.Wait honours + // shellWaitDelay (and on Windows cmd.Cancel spawns taskkill /F /T), so + // TurnDone can arrive almost a full shellWaitDelay after Cancel. Wait + // comfortably longer than that grace — a flat 5s budget equalled + // shellWaitDelay and lost the race on a loaded windows runner. + e := waitForDoneWithin(t, done, shellWaitDelay+10*time.Second) if e.Kind != event.TurnDone { t.Fatalf("done event kind = %v, want TurnDone", e.Kind) } diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index ca1d9d7a0..18d40e5a4 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -431,7 +431,7 @@ func TestStartRecordsTimeoutStats(t *testing.T) { // TestStartPhaseAReturnsBeforePhaseB pins the two-phase handshake contract. // The helper advertises prompts and stalls prompts/list by 200ms; StartAvailable -// must return as soon as tools are ready (well before that 200ms), and the +// must return with tools ready while the prompts surface is still empty, and the // prompts must only materialise on Host after StartPhaseB has been called and // drained — proving prompts ride the background phase, not the boot critical path. func TestStartPhaseAReturnsBeforePhaseB(t *testing.T) { @@ -449,17 +449,17 @@ func TestStartPhaseAReturnsBeforePhaseB(t *testing.T) { }, } - t0 := time.Now() host, tools := StartAvailable(ctx, []Spec{spec}) - startDur := time.Since(t0) defer host.Close() if len(tools) == 0 { t.Fatalf("want tools from helper, got 0") } - if startDur >= 150*time.Millisecond { - t.Fatalf("StartAvailable took %v — phase B (200ms prompts) leaked onto the critical path", startDur) - } + // Phase A returns with tools but the prompts surface must still be empty: + // StartAvailable never issues prompts/list (the helper stalls it 200ms), so + // prompts can only appear after StartPhaseB drains them below. We assert this + // deferral directly instead of timing StartAvailable — subprocess spawn plus + // the MCP handshake make a wall-clock threshold flaky on slow CI runners. if got := host.Prompts(); len(got) != 0 { t.Fatalf("phase A must not surface prompts yet, got %d", len(got)) } From c4e57e16cfba96027a004544a91df57c00265660 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 9 Jun 2026 09:04:44 -0700 Subject: [PATCH 33/64] test(e2e): add subagent-delegation task that forces a `task` sub-agent (#3725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The committed e2e suite has no task that reliably makes the model delegate, so it never exercises the sub-agent path end-to-end. This task seeds three files with arbitrary numbers and instructs the agent to read them via a single `task` sub-agent (not inline), then write their sum to result.txt. Because e2ebench drives the headless `reasonix run` path, this covers a fresh `task` delegation there — the exact path that regressed when persisted sub-agent transcripts made a parent session mandatory. continue_from/fork_from need a persisted session and are out of scope for the headless harness. --- benchmarks/e2e/tasks/subagent-delegation/task.toml | 3 +++ benchmarks/e2e/tasks/subagent-delegation/verify.sh | 12 ++++++++++++ .../tasks/subagent-delegation/workdir/data/alpha.txt | 1 + .../tasks/subagent-delegation/workdir/data/beta.txt | 1 + .../tasks/subagent-delegation/workdir/data/gamma.txt | 1 + 5 files changed, 18 insertions(+) create mode 100644 benchmarks/e2e/tasks/subagent-delegation/task.toml create mode 100644 benchmarks/e2e/tasks/subagent-delegation/verify.sh create mode 100644 benchmarks/e2e/tasks/subagent-delegation/workdir/data/alpha.txt create mode 100644 benchmarks/e2e/tasks/subagent-delegation/workdir/data/beta.txt create mode 100644 benchmarks/e2e/tasks/subagent-delegation/workdir/data/gamma.txt diff --git a/benchmarks/e2e/tasks/subagent-delegation/task.toml b/benchmarks/e2e/tasks/subagent-delegation/task.toml new file mode 100644 index 000000000..509fa48c3 --- /dev/null +++ b/benchmarks/e2e/tasks/subagent-delegation/task.toml @@ -0,0 +1,3 @@ +prompt = "This directory contains a data/ folder with three files: alpha.txt, beta.txt, and gamma.txt. Each file holds a single line of the form name=number. You MUST delegate the reading to a sub-agent: call the `task` tool exactly once, instructing the sub-agent to read all three files under data/ and report the three numbers back to you. Do NOT read the files yourself with your own tools. After the sub-agent reports the numbers, add them together and write ONLY the resulting integer (no other text, no trailing label or newline-prefixed words) to a file named result.txt in the current directory." +max_steps = 15 +timeout_sec = 300 diff --git a/benchmarks/e2e/tasks/subagent-delegation/verify.sh b/benchmarks/e2e/tasks/subagent-delegation/verify.sh new file mode 100644 index 000000000..9bedcc368 --- /dev/null +++ b/benchmarks/e2e/tasks/subagent-delegation/verify.sh @@ -0,0 +1,12 @@ +set -e +# Exercises a fresh `task` sub-agent delegation in the headless `reasonix run` +# path: 17 + 28 + 41 = 86. The numbers are arbitrary so the answer can only be +# produced by actually reading the three seed files (the prompt mandates doing +# that via the `task` tool). Before sub-agents could run without a parent +# session, the `task` call errored here with "parent session is required". +test -f result.txt +got=$(tr -d '[:space:]' < result.txt) +if [ "$got" != "86" ]; then + echo "result.txt = '$got', want 86" + exit 1 +fi diff --git a/benchmarks/e2e/tasks/subagent-delegation/workdir/data/alpha.txt b/benchmarks/e2e/tasks/subagent-delegation/workdir/data/alpha.txt new file mode 100644 index 000000000..0d9353596 --- /dev/null +++ b/benchmarks/e2e/tasks/subagent-delegation/workdir/data/alpha.txt @@ -0,0 +1 @@ +alpha=17 diff --git a/benchmarks/e2e/tasks/subagent-delegation/workdir/data/beta.txt b/benchmarks/e2e/tasks/subagent-delegation/workdir/data/beta.txt new file mode 100644 index 000000000..53aae3d01 --- /dev/null +++ b/benchmarks/e2e/tasks/subagent-delegation/workdir/data/beta.txt @@ -0,0 +1 @@ +beta=28 diff --git a/benchmarks/e2e/tasks/subagent-delegation/workdir/data/gamma.txt b/benchmarks/e2e/tasks/subagent-delegation/workdir/data/gamma.txt new file mode 100644 index 000000000..c9939115d --- /dev/null +++ b/benchmarks/e2e/tasks/subagent-delegation/workdir/data/gamma.txt @@ -0,0 +1 @@ +gamma=41 From 6a51e865f1bba089c137c09656d355c0a2bf028c Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 9 Jun 2026 09:29:35 -0700 Subject: [PATCH 34/64] ci(e2e-bot): build with go.mod's Go version, not pinned 1.22 (#3731) The e2e-bot pinned go-version: '1.22' with setup-go's GOTOOLCHAIN=local, but go.mod now requires go >= 1.25.0, so the very first step ("Build harness + fallback agent from the default branch") fails with: go: go.mod requires go >= 1.25.0 (running go 1.22.12; GOTOOLCHAIN=local) That breaks every /e2e invocation before it can build anything. Every other workflow (ci.yml, release-*.yml) already uses go-version-file: go.mod; mirror that here so the bot tracks the repo's Go version and never goes stale again. --- .github/workflows/e2e-bot.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-bot.yml b/.github/workflows/e2e-bot.yml index 08d522b4a..944f7de87 100644 --- a/.github/workflows/e2e-bot.yml +++ b/.github/workflows/e2e-bot.yml @@ -43,7 +43,7 @@ jobs: - uses: actions/setup-go@v6 with: - go-version: '1.22' + go-version-file: go.mod cache: true - uses: actions/setup-python@v6 From 568644daae1e45f3d41e2144aed013f774d2449d Mon Sep 17 00:00:00 2001 From: lifu963 <56394323+lifu963@users.noreply.github.com> Date: Wed, 10 Jun 2026 07:52:15 +0800 Subject: [PATCH 35/64] Continue subagent transcripts (#3586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent): add subagent transcript store Co-authored-by: Cursor * feat(agent): continue task subagent transcripts Co-authored-by: Cursor * feat(skill): continue subagent skill transcripts Co-authored-by: Cursor * refactor(agent): require subagent transcript store Co-authored-by: Cursor * fix(boot): record effective subagent identity Co-authored-by: Cursor * fix(agent): release source lock after fork copy Co-authored-by: Cursor * fix(cli): run review with explicit subagent session Co-authored-by: Cursor * fix(agent): persist failed subagent transcripts Co-authored-by: Cursor * fix(agent): mark failed foreground continuations Co-authored-by: Cursor * fix(agent): bind subagents to parent sessions Ensure persisted subagent transcripts record the active parent session and prevent in-place continuation from a different parent, keeping fork_from as the explicit copy path. Co-authored-by: Cursor * fix(session): cascade subagent lifecycle cleanup Move owned subagent artifacts with desktop trash/restore and delete them from hard-delete paths so parent session cleanup no longer leaves reusable child transcripts behind. Co-authored-by: Cursor * fix(agent): run sub-agents ephemerally without a parent session Persisted sub-agent transcripts made a parent session mandatory, so `reasonix run` (headless, which never mints a session path) failed every task/subagent-skill call with "subagent transcript parent session is required". Fall back to a non-persisted run when no parent session is active — restoring pre-transcript behaviour — and reserve continue_from/ fork_from for sessions that actually have a persisted owner. Adds a headless-run regression guard plus task-level ephemeral coverage. --------- Co-authored-by: lifu963 Co-authored-by: Cursor Co-authored-by: esengine <359807859@qq.com> --- desktop/sessions.go | 70 ++++ desktop/sessions_test.go | 134 +++++++ desktop/tabs_topic_test.go | 5 + internal/acp/server_test.go | 47 +++ internal/acp/service.go | 3 + internal/agent/agent.go | 13 + internal/agent/subagent_store.go | 495 ++++++++++++++++++++++++++ internal/agent/subagent_store_test.go | 236 ++++++++++++ internal/agent/task.go | 177 ++++++++- internal/agent/task_test.go | 225 +++++++++++- internal/boot/boot.go | 107 +++++- internal/boot/boot_test.go | 285 +++++++++++++++ internal/boot/subagent_model_test.go | 35 ++ internal/cli/review.go | 2 +- internal/control/controller.go | 6 + internal/serve/serve.go | 4 + internal/serve/serve_test.go | 33 ++ internal/skill/tools.go | 27 +- internal/skill/tools_test.go | 36 +- 19 files changed, 1891 insertions(+), 49 deletions(-) create mode 100644 internal/agent/subagent_store.go create mode 100644 internal/agent/subagent_store_test.go diff --git a/desktop/sessions.go b/desktop/sessions.go index 777bbb40a..1b8b5d4ae 100644 --- a/desktop/sessions.go +++ b/desktop/sessions.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "reasonix/internal/agent" "reasonix/internal/fileutil" ) @@ -126,6 +127,9 @@ func trashSessionArtifacts(dir, sessionPath, key string) error { if err := movePathIfExists(strings.TrimSuffix(sessionPath, ".jsonl")+".ckpt", filepath.Join(itemDir, ckptName)); err != nil { return err } + if err := trashSubagentArtifacts(dir, sessionPath, itemDir); err != nil { + return err + } meta := trashedSessionMeta{Key: key, DeletedAt: time.Now().UnixMilli()} b, err := json.MarshalIndent(meta, "", " ") if err != nil { @@ -193,6 +197,9 @@ func restoreTrashedSessionFile(dir, path string) error { if err := os.MkdirAll(dir, 0o755); err != nil { return err } + if err := checkRestoreSubagentConflicts(dir, itemDir); err != nil { + return err + } if err := movePathIfExists(trashPath, target); err != nil { return err } @@ -203,6 +210,9 @@ func restoreTrashedSessionFile(dir, path string) error { if err := movePathIfExists(filepath.Join(itemDir, ckptName), filepath.Join(dir, ckptName)); err != nil { return err } + if err := restoreSubagentArtifacts(dir, itemDir); err != nil { + return err + } return os.RemoveAll(itemDir) } @@ -242,6 +252,66 @@ func movePathIfExists(src, dst string) error { return os.Rename(src, dst) } +func trashSubagentArtifacts(dir, sessionPath, itemDir string) error { + artifacts, err := agent.ListSubagentsByParent(dir, agent.BranchID(sessionPath)) + if err != nil { + return err + } + trashSubagentDir := filepath.Join(itemDir, "subagents") + for _, artifact := range artifacts { + if err := movePathIfExists(artifact.SessionPath, filepath.Join(trashSubagentDir, filepath.Base(artifact.SessionPath))); err != nil { + return err + } + if err := movePathIfExists(artifact.MetaPath, filepath.Join(trashSubagentDir, filepath.Base(artifact.MetaPath))); err != nil { + return err + } + } + return nil +} + +func checkRestoreSubagentConflicts(dir, itemDir string) error { + trashSubagentDir := filepath.Join(itemDir, "subagents") + entries, err := os.ReadDir(trashSubagentDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + target := filepath.Join(dir, "subagents", entry.Name()) + if _, err := os.Stat(target); err == nil { + return fmt.Errorf("subagent artifact already exists: %s", entry.Name()) + } else if !os.IsNotExist(err) { + return err + } + } + return nil +} + +func restoreSubagentArtifacts(dir, itemDir string) error { + trashSubagentDir := filepath.Join(itemDir, "subagents") + entries, err := os.ReadDir(trashSubagentDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + if err := movePathIfExists(filepath.Join(trashSubagentDir, entry.Name()), filepath.Join(dir, "subagents", entry.Name())); err != nil { + return err + } + } + return nil +} + func validateSessionPath(dir, sessionPath string) (string, string, error) { if strings.TrimSpace(sessionPath) == "" { return "", "", fmt.Errorf("empty session path") diff --git a/desktop/sessions_test.go b/desktop/sessions_test.go index 6e9d0035e..6d3b39035 100644 --- a/desktop/sessions_test.go +++ b/desktop/sessions_test.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" "testing" + + "reasonix/internal/agent" ) // --- loadSessionTitles --- @@ -164,6 +166,40 @@ func TestDeleteSessionFile(t *testing.T) { } } +func TestDeleteSessionFileMovesOwnedSubagentsToTrash(t *testing.T) { + dir := t.TempDir() + sessionPath := filepath.Join(dir, "session.jsonl") + os.WriteFile(sessionPath, []byte("data"), 0o644) + writeSubagentArtifact(t, dir, "sa_20260102_030405_000000000_aabbccddeeff", agent.BranchID(sessionPath)) + writeSubagentArtifact(t, dir, "sa_20260102_030405_000000000_112233445566", "other-parent") + + if err := deleteSessionFile(dir, sessionPath); err != nil { + t.Fatalf("delete: %v", err) + } + + ownedJSONL := filepath.Join(dir, "subagents", "sa_20260102_030405_000000000_aabbccddeeff.jsonl") + ownedMeta := filepath.Join(dir, "subagents", "sa_20260102_030405_000000000_aabbccddeeff.meta.json") + if _, err := os.Stat(ownedJSONL); !os.IsNotExist(err) { + t.Fatalf("owned subagent jsonl should be moved out of active dir, stat err = %v", err) + } + if _, err := os.Stat(ownedMeta); !os.IsNotExist(err) { + t.Fatalf("owned subagent meta should be moved out of active dir, stat err = %v", err) + } + trashSubagentDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "subagents") + if _, err := os.Stat(filepath.Join(trashSubagentDir, "sa_20260102_030405_000000000_aabbccddeeff.jsonl")); err != nil { + t.Fatalf("owned subagent jsonl should be in trash: %v", err) + } + if _, err := os.Stat(filepath.Join(trashSubagentDir, "sa_20260102_030405_000000000_aabbccddeeff.meta.json")); err != nil { + t.Fatalf("owned subagent meta should be in trash: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "subagents", "sa_20260102_030405_000000000_112233445566.jsonl")); err != nil { + t.Fatalf("unowned subagent jsonl should remain active: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "subagents", "sa_20260102_030405_000000000_112233445566.meta.json")); err != nil { + t.Fatalf("unowned subagent meta should remain active: %v", err) + } +} + func TestDeleteSessionFileNoTitle(t *testing.T) { dir := t.TempDir() sessionPath := filepath.Join(dir, "no-title.jsonl") @@ -225,6 +261,61 @@ func TestRestoreTrashedSessionFile(t *testing.T) { } } +func TestRestoreTrashedSessionFileRestoresSubagents(t *testing.T) { + dir := t.TempDir() + sessionPath := filepath.Join(dir, "session.jsonl") + if err := os.WriteFile(sessionPath, []byte("data"), 0o644); err != nil { + t.Fatal(err) + } + ref := "sa_20260102_030405_000000000_aabbccddeeff" + writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath)) + if err := deleteSessionFile(dir, sessionPath); err != nil { + t.Fatalf("trash: %v", err) + } + + trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl") + if err := restoreTrashedSessionFile(dir, trashPath); err != nil { + t.Fatalf("restore: %v", err) + } + + if _, err := os.Stat(filepath.Join(dir, "subagents", ref+".jsonl")); err != nil { + t.Fatalf("subagent jsonl should be restored: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "subagents", ref+".meta.json")); err != nil { + t.Fatalf("subagent meta should be restored: %v", err) + } +} + +func TestRestoreTrashedSessionFileRejectsSubagentConflict(t *testing.T) { + dir := t.TempDir() + sessionPath := filepath.Join(dir, "session.jsonl") + if err := os.WriteFile(sessionPath, []byte("data"), 0o644); err != nil { + t.Fatal(err) + } + ref := "sa_20260102_030405_000000000_aabbccddeeff" + writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath)) + if err := deleteSessionFile(dir, sessionPath); err != nil { + t.Fatalf("trash: %v", err) + } + if err := os.MkdirAll(filepath.Join(dir, "subagents"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "subagents", ref+".jsonl"), []byte("conflict"), 0o644); err != nil { + t.Fatal(err) + } + + trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl") + if err := restoreTrashedSessionFile(dir, trashPath); err == nil { + t.Fatal("restore should fail on subagent conflict") + } + if _, err := os.Stat(trashPath); err != nil { + t.Fatalf("trash item should remain after failed restore: %v", err) + } + if _, err := os.Stat(sessionPath); !os.IsNotExist(err) { + t.Fatalf("parent session should not be restored after conflict, stat err = %v", err) + } +} + func TestPurgeTrashedSessionFile(t *testing.T) { dir := t.TempDir() sessionPath := filepath.Join(dir, "session.jsonl") @@ -253,6 +344,24 @@ func TestPurgeTrashedSessionFile(t *testing.T) { } } +func TestPurgeTrashedSessionFileRemovesSubagents(t *testing.T) { + dir := t.TempDir() + sessionPath := filepath.Join(dir, "session.jsonl") + os.WriteFile(sessionPath, []byte("data"), 0o644) + ref := "sa_20260102_030405_000000000_aabbccddeeff" + writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath)) + if err := deleteSessionFile(dir, sessionPath); err != nil { + t.Fatalf("trash: %v", err) + } + trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl") + if err := purgeTrashedSessionFile(dir, trashPath); err != nil { + t.Fatalf("purge: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, sessionTrashDir, "session.jsonl", "subagents", ref+".jsonl")); !os.IsNotExist(err) { + t.Fatalf("trashed subagent should be removed by purge, stat err = %v", err) + } +} + func TestListTrashedSessionFilesRejectsSymlinkEscape(t *testing.T) { dir := t.TempDir() outside := filepath.Join(t.TempDir(), "outside.jsonl") @@ -325,6 +434,31 @@ func TestDeleteSessionFileRejectsSymlinkEscape(t *testing.T) { } } +func writeSubagentArtifact(t *testing.T, dir, ref, parentSession string) { + t.Helper() + subagentDir := filepath.Join(dir, "subagents") + if err := os.MkdirAll(subagentDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(subagentDir, ref+".jsonl"), []byte(`{"role":"user","content":"sub"}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + meta := agent.SubagentMeta{ + Ref: ref, + Status: agent.SubagentCompleted, + Kind: "task", + Name: "task", + ParentSession: parentSession, + } + data, err := json.Marshal(meta) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(subagentDir, ref+".meta.json"), data, 0o644); err != nil { + t.Fatal(err) + } +} + // --- sessionTitlesPath --- func TestSessionTitlesPath(t *testing.T) { diff --git a/desktop/tabs_topic_test.go b/desktop/tabs_topic_test.go index 6e10cccaf..bd555164c 100644 --- a/desktop/tabs_topic_test.go +++ b/desktop/tabs_topic_test.go @@ -907,6 +907,8 @@ func TestTrashTopicMovesRelatedSessionsToTrash(t *testing.T) { t.Fatalf("mkdir sessions: %v", err) } sessionPath := writeTopicSession(t, dir, "trash-me.jsonl", topicID, "Trash history", projectRoot) + ref := "sa_20260102_030405_000000000_aabbccddeeff" + writeSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath)) if err := NewApp().TrashTopic(topicID); err != nil { t.Fatalf("trash topic: %v", err) @@ -918,6 +920,9 @@ func TestTrashTopicMovesRelatedSessionsToTrash(t *testing.T) { if _, err := os.Stat(trashPath); err != nil { t.Fatalf("topic session should be moved to trash: %v", err) } + if _, err := os.Stat(filepath.Join(dir, sessionTrashDir, "trash-me.jsonl", "subagents", ref+".jsonl")); err != nil { + t.Fatalf("topic subagent should be moved to trash: %v", err) + } if got := loadTopicTitle(projectRoot, topicID); got != "" { t.Fatalf("topic title should be removed, got %q", got) } diff --git a/internal/acp/server_test.go b/internal/acp/server_test.go index 001423ecd..9e79b568e 100644 --- a/internal/acp/server_test.go +++ b/internal/acp/server_test.go @@ -4,11 +4,14 @@ import ( "context" "encoding/json" "io" + "os" + "path/filepath" "strings" "sync" "testing" "time" + "reasonix/internal/agent" "reasonix/internal/control" "reasonix/internal/event" ) @@ -376,6 +379,50 @@ func TestServeRejectsPathLikeSessionID(t *testing.T) { } } +func TestDeleteSessionFilesDeletesOwnedSubagents(t *testing.T) { + dir := t.TempDir() + sessionPath := filepath.Join(dir, "session.jsonl") + if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + ref := "sa_20260102_030405_000000000_aabbccddeeff" + writeACPSubagentArtifact(t, dir, ref, agent.BranchID(sessionPath)) + + if err := deleteSessionFiles(sessionPath); err != nil { + t.Fatalf("deleteSessionFiles: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "subagents", ref+".jsonl")); !os.IsNotExist(err) { + t.Fatalf("subagent jsonl should be deleted, stat err = %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "subagents", ref+".meta.json")); !os.IsNotExist(err) { + t.Fatalf("subagent meta should be deleted, stat err = %v", err) + } +} + +func writeACPSubagentArtifact(t *testing.T, dir, ref, parentSession string) { + t.Helper() + subagentDir := filepath.Join(dir, "subagents") + if err := os.MkdirAll(subagentDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(subagentDir, ref+".jsonl"), []byte(`{"role":"user","content":"sub"}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(agent.SubagentMeta{ + Ref: ref, + Status: agent.SubagentCompleted, + Kind: "task", + Name: "task", + ParentSession: parentSession, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(subagentDir, ref+".meta.json"), data, 0o644); err != nil { + t.Fatal(err) + } +} + func TestServeUnknownMethod(t *testing.T) { factory := &fakeFactory{behavior: func(context.Context, event.Sink, string) error { return nil }} client, stop := startServer(t, factory) diff --git a/internal/acp/service.go b/internal/acp/service.go index 659932dfb..ea1eb4ebd 100644 --- a/internal/acp/service.go +++ b/internal/acp/service.go @@ -917,6 +917,9 @@ func deleteSessionFiles(sessionPath string) error { return err } } + if err := agent.DeleteSubagentsByParent(filepath.Dir(sessionPath), agent.BranchID(sessionPath)); err != nil { + return err + } return nil } diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 063a0fa70..269bfc5e5 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -53,6 +53,7 @@ type Asker interface { // callContextKey carries the executing tool call's identity into Execute. type callContextKey struct{} +type parentSessionContextKey struct{} // callContext is the per-call context a tool can read. parentID is the call being // executed and sink is the agent's event sink (the `task` tool uses both to nest @@ -81,6 +82,18 @@ func CallContext(ctx context.Context) (parentID string, sink event.Sink, asker A return cc.parentID, cc.sink, cc.asker, true } +// WithParentSession stamps the active parent session ID onto a turn context so +// persisted sub-agents can record and enforce their owning conversation. +func WithParentSession(ctx context.Context, parentSession string) context.Context { + return context.WithValue(ctx, parentSessionContextKey{}, strings.TrimSpace(parentSession)) +} + +// ParentSession returns the active parent session ID carried by a turn context. +func ParentSession(ctx context.Context) string { + parentSession, _ := ctx.Value(parentSessionContextKey{}).(string) + return strings.TrimSpace(parentSession) +} + // Gate decides, per tool call, whether it may run. The agent consults it at // execute time (after the plan-mode gate). It is interface-shaped so the agent // stays independent of the permission package and of how "ask" is resolved diff --git a/internal/agent/subagent_store.go b/internal/agent/subagent_store.go new file mode 100644 index 000000000..d61f8194e --- /dev/null +++ b/internal/agent/subagent_store.go @@ -0,0 +1,495 @@ +package agent + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "reasonix/internal/fileutil" + "reasonix/internal/tool" +) + +type SubagentStatus string + +const ( + SubagentRunning SubagentStatus = "running" + SubagentCompleted SubagentStatus = "completed" + SubagentFailed SubagentStatus = "failed" +) + +// SubagentMeta is the sidecar for a persisted sub-agent transcript. It captures +// the execution identity that must stay stable for continuation/fork. +type SubagentMeta struct { + Ref string `json:"ref"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Status SubagentStatus `json:"status"` + Kind string `json:"kind"` // task | skill + Name string `json:"name"` + WorkspaceRoot string `json:"workspaceRoot"` + ParentSession string `json:"parentSession,omitempty"` + ParentToolCallID string `json:"parentToolCallId,omitempty"` + SystemPromptHash string `json:"systemPromptHash"` + ToolScope []string `json:"toolScope"` + ToolSchemaHash string `json:"toolSchemaHash"` + Model string `json:"model"` + Effort string `json:"effort"` +} + +// SubagentSpec describes the current invocation identity. +type SubagentSpec struct { + Kind string + Name string + WorkspaceRoot string + ParentSession string + ParentToolCallID string + SystemPrompt string + Registry *tool.Registry + Model string + Effort string +} + +// SubagentRun is a prepared transcript run. Call Release exactly once. +type SubagentRun struct { + Ref string + Session *Session + Meta SubagentMeta + + store *SubagentStore + release func() +} + +// SubagentArtifact is a persisted sub-agent transcript and metadata pair owned +// by a parent session. One file may be missing after a crash; lifecycle cleanup +// should operate on the paths that exist. +type SubagentArtifact struct { + Ref string + SessionPath string + MetaPath string + Meta SubagentMeta +} + +func (r *SubagentRun) Release() { + if r != nil && r.release != nil { + r.release() + r.release = nil + } +} + +// EphemeralSubagentRun is a non-persisted run for callers without an owning +// parent session — e.g. headless `reasonix run`, which never mints a session +// path. Its empty Ref makes the store's MarkRunning/SaveCompleted/SaveFailed +// methods no-op and keeps FormatSubagentResult from emitting a transcript +// reference, so the sub-agent behaves exactly as it did before persisted +// transcripts existed. It holds no lock, so Release is a no-op. +func EphemeralSubagentRun(systemPrompt string) *SubagentRun { + return &SubagentRun{Session: NewSession(systemPrompt)} +} + +// SubagentStore persists sub-agent transcripts under config.SessionDir()/subagents. +// Its locks are process-local; cross-process mutation is intentionally out of v1. +type SubagentStore struct { + dir string + + mu sync.Mutex + locked map[string]bool +} + +func NewSubagentStore(dir string) *SubagentStore { + if strings.TrimSpace(dir) == "" { + return nil + } + return &SubagentStore{dir: dir, locked: map[string]bool{}} +} + +// ListSubagentsByParent returns persisted sub-agent artifacts whose metadata +// declares the given parent session owner. +func ListSubagentsByParent(sessionDir, parentSession string) ([]SubagentArtifact, error) { + parentSession = strings.TrimSpace(parentSession) + if strings.TrimSpace(sessionDir) == "" || parentSession == "" { + return nil, nil + } + dir := filepath.Join(sessionDir, "subagents") + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + out := []SubagentArtifact{} + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".meta.json") { + continue + } + ref := strings.TrimSuffix(entry.Name(), ".meta.json") + if !validSubagentRef(ref) { + continue + } + metaPath := filepath.Join(dir, entry.Name()) + data, err := os.ReadFile(metaPath) + if err != nil { + return nil, err + } + var meta SubagentMeta + if err := json.Unmarshal(data, &meta); err != nil { + continue + } + if strings.TrimSpace(meta.ParentSession) != parentSession { + continue + } + out = append(out, SubagentArtifact{ + Ref: ref, + SessionPath: filepath.Join(dir, ref+".jsonl"), + MetaPath: metaPath, + Meta: meta, + }) + } + return out, nil +} + +// DeleteSubagentsByParent permanently removes sub-agent artifacts owned by a +// parent session. Missing counterpart files are ignored. +func DeleteSubagentsByParent(sessionDir, parentSession string) error { + artifacts, err := ListSubagentsByParent(sessionDir, parentSession) + if err != nil { + return err + } + for _, artifact := range artifacts { + for _, path := range []string{artifact.SessionPath, artifact.MetaPath} { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + } + } + return nil +} + +func (s *SubagentStore) PrepareFresh(spec SubagentSpec) (*SubagentRun, error) { + if s == nil { + return nil, fmt.Errorf("subagent transcript store is required") + } + if err := requireParentSession(spec); err != nil { + return nil, err + } + ref, err := s.newRef() + if err != nil { + return nil, err + } + release, err := s.lock(ref) + if err != nil { + return nil, err + } + now := time.Now().UTC() + meta := metaFromSpec(ref, SubagentRunning, now, now, spec) + return &SubagentRun{Ref: ref, Session: NewSession(spec.SystemPrompt), Meta: meta, store: s, release: release}, nil +} + +func (s *SubagentStore) PrepareContinue(ref string, spec SubagentSpec) (*SubagentRun, error) { + if s == nil { + return nil, fmt.Errorf("subagent continuation is not available in this session") + } + if err := requireParentSession(spec); err != nil { + return nil, err + } + ref = strings.TrimSpace(ref) + if ref == "" { + return nil, fmt.Errorf("continue_from requires a subagent reference") + } + release, err := s.lock(ref) + if err != nil { + return nil, err + } + meta, err := s.LoadMeta(ref) + if err != nil { + release() + return nil, err + } + if err := validateContinueOwner(meta, spec); err != nil { + release() + return nil, err + } + if err := validateMeta(meta, spec); err != nil { + release() + return nil, err + } + sess, err := LoadSession(s.sessionPath(ref)) + if err != nil { + release() + return nil, fmt.Errorf("load subagent transcript %q: %w", ref, err) + } + meta.ParentSession = spec.ParentSession + meta.ParentToolCallID = spec.ParentToolCallID + return &SubagentRun{Ref: ref, Session: sess, Meta: meta, store: s, release: release}, nil +} + +func (s *SubagentStore) PrepareFork(ref string, spec SubagentSpec) (*SubagentRun, error) { + if s == nil { + return nil, fmt.Errorf("subagent continuation is not available in this session") + } + if err := requireParentSession(spec); err != nil { + return nil, err + } + sourceRef := strings.TrimSpace(ref) + if sourceRef == "" { + return nil, fmt.Errorf("fork_from requires a subagent reference") + } + sourceRelease, err := s.lock(sourceRef) + if err != nil { + return nil, err + } + meta, err := s.LoadMeta(sourceRef) + if err != nil { + sourceRelease() + return nil, err + } + if strings.TrimSpace(meta.ParentSession) == "" { + sourceRelease() + return nil, fmt.Errorf("subagent reference %q has no parent session; run a fresh subagent instead", sourceRef) + } + if err := validateMeta(meta, spec); err != nil { + sourceRelease() + return nil, err + } + sess, err := LoadSession(s.sessionPath(sourceRef)) + if err != nil { + sourceRelease() + return nil, fmt.Errorf("load subagent transcript %q: %w", sourceRef, err) + } + sourceRelease() + newRef, err := s.newRef() + if err != nil { + return nil, err + } + newRelease, err := s.lock(newRef) + if err != nil { + return nil, err + } + now := time.Now().UTC() + newMeta := metaFromSpec(newRef, SubagentRunning, now, now, spec) + return &SubagentRun{Ref: newRef, Session: sess, Meta: newMeta, store: s, release: newRelease}, nil +} + +func (s *SubagentStore) MarkRunning(run *SubagentRun) error { + if s == nil || run == nil || run.Ref == "" { + return nil + } + meta := run.Meta + meta.Status = SubagentRunning + meta.UpdatedAt = time.Now().UTC() + return s.saveMeta(meta) +} + +func (s *SubagentStore) SaveCompleted(run *SubagentRun) error { + if s == nil || run == nil || run.Ref == "" { + return nil + } + if err := run.Session.Save(s.sessionPath(run.Ref)); err != nil { + return err + } + meta := run.Meta + meta.Status = SubagentCompleted + meta.UpdatedAt = time.Now().UTC() + run.Meta = meta + return s.saveMeta(meta) +} + +func (s *SubagentStore) SaveFailed(run *SubagentRun) error { + if s == nil || run == nil || run.Ref == "" { + return nil + } + var sessionErr error + if run.Session != nil { + sessionErr = run.Session.Save(s.sessionPath(run.Ref)) + } + meta := run.Meta + meta.Status = SubagentFailed + meta.UpdatedAt = time.Now().UTC() + run.Meta = meta + return errors.Join(sessionErr, s.saveMeta(meta)) +} + +func (s *SubagentStore) LoadMeta(ref string) (SubagentMeta, error) { + var meta SubagentMeta + if !validSubagentRef(ref) { + return meta, fmt.Errorf("invalid subagent reference %q", ref) + } + data, err := os.ReadFile(s.metaPath(ref)) + if err != nil { + return meta, fmt.Errorf("load subagent metadata %q: %w", ref, err) + } + if err := json.Unmarshal(data, &meta); err != nil { + return meta, fmt.Errorf("decode subagent metadata %q: %w", ref, err) + } + return meta, nil +} + +func metaFromSpec(ref string, status SubagentStatus, created, updated time.Time, spec SubagentSpec) SubagentMeta { + scope, schemaHash := toolIdentity(spec.Registry) + return SubagentMeta{ + Ref: ref, + CreatedAt: created, + UpdatedAt: updated, + Status: status, + Kind: strings.TrimSpace(spec.Kind), + Name: strings.TrimSpace(spec.Name), + WorkspaceRoot: strings.TrimSpace(spec.WorkspaceRoot), + ParentSession: strings.TrimSpace(spec.ParentSession), + ParentToolCallID: strings.TrimSpace(spec.ParentToolCallID), + SystemPromptHash: bytesHash([]byte(spec.SystemPrompt)), + ToolScope: scope, + ToolSchemaHash: schemaHash, + Model: strings.TrimSpace(spec.Model), + Effort: strings.TrimSpace(spec.Effort), + } +} + +func validateMeta(meta SubagentMeta, spec SubagentSpec) error { + if meta.Status == SubagentRunning { + return fmt.Errorf("subagent reference %q is still in progress", meta.Ref) + } + if meta.Status == SubagentFailed { + return fmt.Errorf("subagent reference %q failed and cannot be continued", meta.Ref) + } + want := metaFromSpec(meta.Ref, meta.Status, meta.CreatedAt, meta.UpdatedAt, spec) + switch { + case meta.Kind != want.Kind: + return fmt.Errorf("subagent reference %q has kind %q, want %q", meta.Ref, meta.Kind, want.Kind) + case meta.Name != want.Name: + return fmt.Errorf("subagent reference %q has name %q, want %q", meta.Ref, meta.Name, want.Name) + case meta.WorkspaceRoot != want.WorkspaceRoot: + return fmt.Errorf("subagent reference %q belongs to workspace %q, current workspace is %q", meta.Ref, meta.WorkspaceRoot, want.WorkspaceRoot) + case meta.SystemPromptHash != want.SystemPromptHash: + return fmt.Errorf("subagent reference %q uses a different subagent persona; run a fresh subagent to use the current persona", meta.Ref) + case !sameStrings(meta.ToolScope, want.ToolScope): + return fmt.Errorf("subagent reference %q uses a different tool scope", meta.Ref) + case meta.ToolSchemaHash != want.ToolSchemaHash: + return fmt.Errorf("subagent reference %q uses different tool schemas", meta.Ref) + case meta.Model != want.Model || meta.Effort != want.Effort: + return fmt.Errorf("subagent reference %q uses model/effort %q/%q, current run would use %q/%q", meta.Ref, meta.Model, meta.Effort, want.Model, want.Effort) + } + return nil +} + +func requireParentSession(spec SubagentSpec) error { + if strings.TrimSpace(spec.ParentSession) == "" { + return fmt.Errorf("subagent transcript parent session is required") + } + return nil +} + +func validateContinueOwner(meta SubagentMeta, spec SubagentSpec) error { + current := strings.TrimSpace(spec.ParentSession) + owner := strings.TrimSpace(meta.ParentSession) + if owner == current { + return nil + } + if owner == "" { + return fmt.Errorf("subagent reference %q has no parent session; run a fresh subagent instead", meta.Ref) + } + return fmt.Errorf("subagent reference %q belongs to parent session %q, current parent session is %q; use fork_from to copy it into this session", meta.Ref, owner, current) +} + +func (s *SubagentStore) lock(ref string) (func(), error) { + if !validSubagentRef(ref) { + return nil, fmt.Errorf("invalid subagent reference %q", ref) + } + s.mu.Lock() + defer s.mu.Unlock() + if s.locked[ref] { + return nil, fmt.Errorf("subagent reference %q is already running; retry after it finishes", ref) + } + s.locked[ref] = true + return func() { + s.mu.Lock() + delete(s.locked, ref) + s.mu.Unlock() + }, nil +} + +func (s *SubagentStore) newRef() (string, error) { + var b [6]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return "sa_" + time.Now().UTC().Format("20060102_150405_000000000") + "_" + hex.EncodeToString(b[:]), nil +} + +func (s *SubagentStore) sessionPath(ref string) string { return filepath.Join(s.dir, ref+".jsonl") } +func (s *SubagentStore) metaPath(ref string) string { return filepath.Join(s.dir, ref+".meta.json") } + +func (s *SubagentStore) saveMeta(meta SubagentMeta) error { + if err := os.MkdirAll(s.dir, 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + tmp, err := os.CreateTemp(s.dir, ".subagent-meta.*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpPath) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return err + } + return fileutil.ReplaceFile(tmpPath, s.metaPath(meta.Ref)) +} + +func validSubagentRef(ref string) bool { + if !strings.HasPrefix(ref, "sa_") { + return false + } + for _, r := range ref { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { + continue + } + return false + } + return true +} + +func toolIdentity(reg *tool.Registry) ([]string, string) { + if reg == nil { + return nil, bytesHash(nil) + } + names := reg.Names() + sort.Strings(names) + schemas := normalizeToolSchemas(reg.Schemas()) + data, _ := json.Marshal(schemas) + return names, bytesHash(data) +} + +func bytesHash(data []byte) string { + h := sha256.Sum256(data) + return hex.EncodeToString(h[:]) +} + +func sameStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/agent/subagent_store_test.go b/internal/agent/subagent_store_test.go new file mode 100644 index 000000000..2b0d97760 --- /dev/null +++ b/internal/agent/subagent_store_test.go @@ -0,0 +1,236 @@ +package agent + +import ( + "strings" + "testing" + + "reasonix/internal/provider" + "reasonix/internal/tool" +) + +func TestSubagentStoreContinueLoadsSavedTranscript(t *testing.T) { + store := NewSubagentStore(t.TempDir()) + spec := testSubagentSpec(t, "review") + run, err := store.PrepareFresh(spec) + if err != nil { + t.Fatalf("PrepareFresh: %v", err) + } + run.Session.Add(provider.Message{Role: provider.RoleUser, Content: "review diff"}) + run.Session.Add(provider.Message{Role: provider.RoleAssistant, Content: "finding A"}) + if err := store.SaveCompleted(run); err != nil { + t.Fatalf("SaveCompleted: %v", err) + } + run.Release() + + continued, err := store.PrepareContinue(run.Ref, spec) + if err != nil { + t.Fatalf("PrepareContinue: %v", err) + } + defer continued.Release() + if continued.Ref != run.Ref { + t.Fatalf("continued ref = %q, want %q", continued.Ref, run.Ref) + } + if got := continued.Session.Snapshot(); len(got) != 3 || got[2].Content != "finding A" { + t.Fatalf("continued transcript = %+v, want saved messages", got) + } +} + +func TestSubagentStoreForkCreatesIndependentReference(t *testing.T) { + store := NewSubagentStore(t.TempDir()) + spec := testSubagentSpec(t, "review") + run, err := store.PrepareFresh(spec) + if err != nil { + t.Fatalf("PrepareFresh: %v", err) + } + run.Session.Add(provider.Message{Role: provider.RoleUser, Content: "review diff"}) + if err := store.SaveCompleted(run); err != nil { + t.Fatalf("SaveCompleted: %v", err) + } + run.Release() + + forked, err := store.PrepareFork(run.Ref, spec) + if err != nil { + t.Fatalf("PrepareFork: %v", err) + } + defer forked.Release() + if forked.Ref == run.Ref { + t.Fatalf("fork ref should be new, got %q", forked.Ref) + } + if got := forked.Session.Snapshot(); len(got) != 2 || got[1].Content != "review diff" { + t.Fatalf("fork transcript = %+v, want copied messages", got) + } + if forked.Meta.ParentSession != spec.ParentSession { + t.Fatalf("fork parent session = %q, want %q", forked.Meta.ParentSession, spec.ParentSession) + } +} + +func TestSubagentStoreRejectsContinueFromDifferentParentSession(t *testing.T) { + store := NewSubagentStore(t.TempDir()) + spec := testSubagentSpec(t, "review") + run, err := store.PrepareFresh(spec) + if err != nil { + t.Fatalf("PrepareFresh: %v", err) + } + if err := store.SaveCompleted(run); err != nil { + t.Fatalf("SaveCompleted: %v", err) + } + run.Release() + + other := spec + other.ParentSession = "other-parent" + if _, err := store.PrepareContinue(run.Ref, other); err == nil || !strings.Contains(err.Error(), "use fork_from") { + t.Fatalf("PrepareContinue error = %v, want parent ownership failure", err) + } +} + +func TestSubagentStoreForkFromDifferentParentSessionCreatesCurrentOwner(t *testing.T) { + store := NewSubagentStore(t.TempDir()) + spec := testSubagentSpec(t, "review") + run, err := store.PrepareFresh(spec) + if err != nil { + t.Fatalf("PrepareFresh: %v", err) + } + run.Session.Add(provider.Message{Role: provider.RoleUser, Content: "review diff"}) + if err := store.SaveCompleted(run); err != nil { + t.Fatalf("SaveCompleted: %v", err) + } + run.Release() + + other := spec + other.ParentSession = "other-parent" + forked, err := store.PrepareFork(run.Ref, other) + if err != nil { + t.Fatalf("PrepareFork: %v", err) + } + defer forked.Release() + if forked.Ref == run.Ref { + t.Fatalf("fork ref should be new, got %q", forked.Ref) + } + if forked.Meta.ParentSession != "other-parent" { + t.Fatalf("fork parent session = %q, want other-parent", forked.Meta.ParentSession) + } + sourceMeta, err := store.LoadMeta(run.Ref) + if err != nil { + t.Fatalf("LoadMeta source: %v", err) + } + if sourceMeta.ParentSession != spec.ParentSession { + t.Fatalf("source parent session = %q, want %q", sourceMeta.ParentSession, spec.ParentSession) + } +} + +func TestSubagentStoreForkReleasesSourceLockAfterCopy(t *testing.T) { + store := NewSubagentStore(t.TempDir()) + spec := testSubagentSpec(t, "review") + run, err := store.PrepareFresh(spec) + if err != nil { + t.Fatalf("PrepareFresh: %v", err) + } + run.Session.Add(provider.Message{Role: provider.RoleUser, Content: "review diff"}) + if err := store.SaveCompleted(run); err != nil { + t.Fatalf("SaveCompleted: %v", err) + } + run.Release() + + forked, err := store.PrepareFork(run.Ref, spec) + if err != nil { + t.Fatalf("PrepareFork: %v", err) + } + defer forked.Release() + continued, err := store.PrepareContinue(run.Ref, spec) + if err != nil { + t.Fatalf("source should not stay locked by fork run: %v", err) + } + continued.Release() +} + +func TestSubagentStoreRejectsIncompatibleTranscript(t *testing.T) { + store := NewSubagentStore(t.TempDir()) + spec := testSubagentSpec(t, "review") + run, err := store.PrepareFresh(spec) + if err != nil { + t.Fatalf("PrepareFresh: %v", err) + } + if err := store.SaveCompleted(run); err != nil { + t.Fatalf("SaveCompleted: %v", err) + } + run.Release() + + other := spec + other.Name = "security-review" + if _, err := store.PrepareContinue(run.Ref, other); err == nil || !strings.Contains(err.Error(), "name") { + t.Fatalf("PrepareContinue error = %v, want incompatible name", err) + } +} + +func TestSubagentStoreRejectsConcurrentContinue(t *testing.T) { + store := NewSubagentStore(t.TempDir()) + spec := testSubagentSpec(t, "review") + run, err := store.PrepareFresh(spec) + if err != nil { + t.Fatalf("PrepareFresh: %v", err) + } + if err := store.SaveCompleted(run); err != nil { + t.Fatalf("SaveCompleted: %v", err) + } + run.Release() + + first, err := store.PrepareContinue(run.Ref, spec) + if err != nil { + t.Fatalf("first PrepareContinue: %v", err) + } + defer first.Release() + if _, err := store.PrepareContinue(run.Ref, spec); err == nil || !strings.Contains(err.Error(), "already running") { + t.Fatalf("second PrepareContinue error = %v, want lock error", err) + } +} + +func TestSubagentStoreSaveFailedPersistsTranscriptAndRejectsReuse(t *testing.T) { + store := NewSubagentStore(t.TempDir()) + spec := testSubagentSpec(t, "review") + run, err := store.PrepareFresh(spec) + if err != nil { + t.Fatalf("PrepareFresh: %v", err) + } + run.Session.Add(provider.Message{Role: provider.RoleUser, Content: "failed continuation"}) + if err := store.SaveFailed(run); err != nil { + t.Fatalf("SaveFailed: %v", err) + } + run.Release() + + loaded, err := LoadSession(store.sessionPath(run.Ref)) + if err != nil { + t.Fatalf("LoadSession: %v", err) + } + if got := loaded.Snapshot(); len(got) != 2 || got[1].Content != "failed continuation" { + t.Fatalf("failed transcript = %+v, want persisted failed prompt", got) + } + meta, err := store.LoadMeta(run.Ref) + if err != nil { + t.Fatalf("LoadMeta: %v", err) + } + if meta.Status != SubagentFailed { + t.Fatalf("status = %q, want failed", meta.Status) + } + if _, err := store.PrepareContinue(run.Ref, spec); err == nil || !strings.Contains(err.Error(), "failed and cannot be continued") { + t.Fatalf("PrepareContinue error = %v, want failed ref rejection", err) + } + if _, err := store.PrepareFork(run.Ref, spec); err == nil || !strings.Contains(err.Error(), "failed and cannot be continued") { + t.Fatalf("PrepareFork error = %v, want failed ref rejection", err) + } +} + +func testSubagentSpec(t *testing.T, name string) SubagentSpec { + t.Helper() + reg := tool.NewRegistry() + reg.Add(fakeTool{name: "read_file", readOnly: true}) + return SubagentSpec{ + Kind: "skill", + Name: name, + WorkspaceRoot: t.TempDir(), + ParentSession: "parent-session", + SystemPrompt: "review persona", + Registry: reg, + Model: "deepseek", + Effort: "max", + } +} diff --git a/internal/agent/task.go b/internal/agent/task.go index 74373251c..fe3de071d 100644 --- a/internal/agent/task.go +++ b/internal/agent/task.go @@ -3,6 +3,7 @@ package agent import ( "context" "encoding/json" + "errors" "fmt" "io" "strings" @@ -66,6 +67,11 @@ type TaskTool struct { subagentModel string subagentEffort string resolveProvider func(modelRef, effort string) (provider.Provider, *provider.Pricing, int, error) + transcripts *SubagentStore + workspaceRoot string + baseModel string + baseEffort string + identityProfile func(modelRef, effort string) (string, string) } // NewTaskTool wires a task tool to the parent agent's environment so its @@ -99,6 +105,22 @@ func NewTaskTool(prov provider.Provider, pricing *provider.Pricing, parentReg *t } } +// WithTranscripts enables persisted sub-agent transcript continuation for this +// task tool. The base model/effort are the parent provider identity used when no +// subagent override is configured. +func (t *TaskTool) WithTranscripts(store *SubagentStore, workspaceRoot, baseModel, baseEffort string) *TaskTool { + t.transcripts = store + t.workspaceRoot = strings.TrimSpace(workspaceRoot) + t.baseModel = strings.TrimSpace(baseModel) + t.baseEffort = strings.TrimSpace(baseEffort) + return t +} + +func (t *TaskTool) WithTranscriptIdentityResolver(resolve func(modelRef, effort string) (string, string)) *TaskTool { + t.identityProfile = resolve + return t +} + func (t *TaskTool) Name() string { return "task" } func (t *TaskTool) Description() string { @@ -115,7 +137,9 @@ func (t *TaskTool) Schema() json.RawMessage { "max_steps":{"type":"integer","description":"Optional cap on tool-call rounds. Defaults to half the parent's cap (min 5).","minimum":1}, "run_in_background":{"type":"boolean","description":"Run the sub-agent asynchronously: returns a job id immediately and keeps working across turns. Collect its final answer with wait, and you'll be notified when it finishes. Use for long, independent sub-tasks you don't need to block on right now."}, "model":{"type":"string","description":"Optional model override for the sub-agent (a configured provider/model name)."}, - "effort":{"type":"string","description":"Optional reasoning effort for the sub-agent (e.g. high, max)."} + "effort":{"type":"string","description":"Optional reasoning effort for the sub-agent (e.g. high, max)."}, + "continue_from":{"type":"string","description":"Optional subagent transcript reference to continue in place. The current kind, prompt persona, tools, model, effort, and workspace must match the saved transcript."}, + "fork_from":{"type":"string","description":"Optional subagent transcript reference to copy into a new transcript before running this task. Mutually exclusive with continue_from."} }, "required":["prompt"] }`) @@ -163,6 +187,8 @@ func (t *TaskTool) Execute(ctx context.Context, args json.RawMessage) (string, e RunInBackground bool `json:"run_in_background"` Model string `json:"model"` Effort string `json:"effort"` + ContinueFrom string `json:"continue_from"` + ForkFrom string `json:"fork_from"` } if err := json.Unmarshal(args, &p); err != nil { return "", fmt.Errorf("invalid args: %w", err) @@ -188,6 +214,16 @@ func (t *TaskTool) Execute(ctx context.Context, args json.RawMessage) (string, e subReg := t.buildSubReg(p.Tools) modelRef, effortRef := t.effectiveProfile(p.Model, p.Effort) + parentID, parent, _, _ := CallContext(ctx) + run, err := t.prepareTranscriptRun(subReg, modelRef, effortRef, ParentSession(ctx), parentID, p.ContinueFrom, p.ForkFrom) + if err != nil { + return "", err + } + prov, pricing, ctxWin, err := t.resolveSubSessionRuntime(modelRef, effortRef) + if err != nil { + run.Release() + return "", fmt.Errorf("sub-agent profile: %w", err) + } // Background: register a job that runs the sub-agent under the manager's // session context (so it survives this turn) and return immediately. The @@ -196,22 +232,115 @@ func (t *TaskTool) Execute(ctx context.Context, args json.RawMessage) (string, e if p.RunInBackground { jm, ok := jobs.FromContext(ctx) if !ok { + if run != nil { + run.Release() + } return "", fmt.Errorf("background execution is not available in this context") } - parentID, parent, _, _ := CallContext(ctx) nested := subSinkFor(parentID, parent) label := p.Description if label == "" { label = "task" } + if t.transcripts != nil && run != nil && run.Ref != "" { + if err := t.transcripts.MarkRunning(run); err != nil { + run.Release() + return "", err + } + } job := jm.Start("task", label, func(jobCtx context.Context, _ io.Writer) (string, error) { - return t.runSub(jobCtx, p.Prompt, subReg, nested, maxSteps, modelRef, effortRef) + defer run.Release() + answer, err := t.runSubSession(jobCtx, p.Prompt, subReg, nested, maxSteps, prov, pricing, ctxWin, run.Session) + if err != nil { + return FormatSubagentResult("", run.Ref, true), errors.Join(err, t.transcripts.SaveFailed(run)) + } + if err := t.transcripts.SaveCompleted(run); err != nil { + return FormatSubagentResult("", run.Ref, true), errors.Join(err, t.transcripts.SaveFailed(run)) + } + return FormatSubagentResult(answer, run.Ref, false), nil }) + if run != nil && run.Ref != "" { + return fmt.Sprintf("Started background task %q (%s).\nSubagent reference: %s\nIt runs across turns; collect its final answer with wait (or wait will return it once done), and you'll be notified when it finishes.", job.ID, label, run.Ref), nil + } return fmt.Sprintf("Started background task %q (%s). It runs across turns; collect its final answer with wait (or wait will return it once done), and you'll be notified when it finishes.", job.ID, label), nil } // Foreground: run synchronously, nesting events under this call. - return t.runSub(ctx, p.Prompt, subReg, subSink(ctx), maxSteps, modelRef, effortRef) + defer run.Release() + answer, err := t.runSubSession(ctx, p.Prompt, subReg, subSink(ctx), maxSteps, prov, pricing, ctxWin, run.Session) + if err != nil { + return "", errors.Join(err, t.transcripts.SaveFailed(run)) + } + if t.transcripts != nil && run.Ref != "" { + if err := t.transcripts.SaveCompleted(run); err != nil { + return "", errors.Join(err, t.transcripts.SaveFailed(run)) + } + return FormatSubagentResult(answer, run.Ref, false), nil + } + return answer, nil +} + +func (t *TaskTool) prepareTranscriptRun(subReg *tool.Registry, modelRef, effortRef, parentSession, parentID, continueFrom, forkFrom string) (*SubagentRun, error) { + continueFrom = strings.TrimSpace(continueFrom) + forkFrom = strings.TrimSpace(forkFrom) + parentSession = strings.TrimSpace(parentSession) + if continueFrom != "" && forkFrom != "" { + return nil, fmt.Errorf("continue_from and fork_from are mutually exclusive") + } + if t.transcripts == nil { + return nil, fmt.Errorf("subagent transcript store is required") + } + // Headless runs (e.g. `reasonix run`) never mint a session path, so there is + // no parent session to own a transcript. Run the sub-agent ephemerally — + // exactly as before persisted transcripts existed — instead of failing the + // call. Continuation/fork need a persisted owner, so they error here. + if parentSession == "" { + if continueFrom != "" || forkFrom != "" { + return nil, fmt.Errorf("continue_from/fork_from require a persisted session; none is active in this run") + } + return EphemeralSubagentRun(t.sysPrompt), nil + } + identityModel, identityEffort := t.effectiveIdentity(modelRef, effortRef) + spec := SubagentSpec{ + Kind: "task", + Name: "task", + WorkspaceRoot: t.workspaceRoot, + ParentSession: parentSession, + ParentToolCallID: parentID, + SystemPrompt: t.sysPrompt, + Registry: subReg, + Model: identityModel, + Effort: identityEffort, + } + if continueFrom != "" || forkFrom != "" { + if continueFrom != "" { + return t.transcripts.PrepareContinue(continueFrom, spec) + } + return t.transcripts.PrepareFork(forkFrom, spec) + } + return t.transcripts.PrepareFresh(spec) +} + +func (t *TaskTool) effectiveIdentity(modelRef, effort string) (string, string) { + if t.identityProfile != nil { + model, eff := t.identityProfile(modelRef, effort) + return strings.TrimSpace(model), strings.TrimSpace(eff) + } + return t.effectiveModelIdentity(modelRef), t.effectiveEffortIdentity(effort) +} + +func (t *TaskTool) effectiveModelIdentity(modelRef string) string { + if strings.TrimSpace(modelRef) != "" { + return strings.TrimSpace(modelRef) + } + return strings.TrimSpace(t.baseModel) +} + +func (t *TaskTool) effectiveEffortIdentity(effort string) string { + if strings.TrimSpace(effort) != "" { + return strings.TrimSpace(effort) + } + return strings.TrimSpace(t.baseEffort) } // buildSubReg returns the sub-agent's tool set: the named whitelist (minus @@ -288,19 +417,20 @@ func FilterReadOnlyRegistry(parent *tool.Registry, exclude ...string) *tool.Regi return sub } -// runSub builds a sub-agent over subReg, runs prompt to completion emitting to -// sink, and returns its final assistant answer. Shared by the foreground and -// background paths. modelRef and effort override the parent defaults when non-empty. -func (t *TaskTool) runSub(ctx context.Context, prompt string, subReg *tool.Registry, sink event.Sink, maxSteps int, modelRef, effort string) (string, error) { +func (t *TaskTool) resolveSubSessionRuntime(modelRef, effort string) (provider.Provider, *provider.Pricing, int, error) { prov, pricing, ctxWin := t.prov, t.pricing, t.contextWindow if t.resolveProvider != nil && (modelRef != "" || effort != "") { p, pr, cw, err := t.resolveProvider(modelRef, effort) if err != nil { - return "", fmt.Errorf("sub-agent profile: %w", err) + return nil, nil, 0, err } prov, pricing, ctxWin = p, pr, cw } - return RunSubAgent(ctx, prov, subReg, t.sysPrompt, prompt, Options{ + return prov, pricing, ctxWin, nil +} + +func (t *TaskTool) runSubSession(ctx context.Context, prompt string, subReg *tool.Registry, sink event.Sink, maxSteps int, prov provider.Provider, pricing *provider.Pricing, ctxWin int, sess *Session) (string, error) { + return RunSubAgentWithSession(ctx, prov, subReg, sess, prompt, Options{ MaxSteps: maxSteps, Temperature: t.temperature, Pricing: pricing, @@ -313,13 +443,26 @@ func (t *TaskTool) runSub(ctx context.Context, prompt string, subReg *tool.Regis }, sink) } -// RunSubAgent runs prompt to completion in a fresh sub-agent session over reg, -// emitting tool activity to sink, and returns the sub-agent's final assistant -// answer. It is the shared core behind the `task` tool and subagent skills: a -// caller supplies the system prompt (the task persona or the skill body), the -// tool registry (already filtered), and the run Options (model budget, gate). -func RunSubAgent(ctx context.Context, prov provider.Provider, reg *tool.Registry, sysPrompt, prompt string, opts Options, sink event.Sink) (string, error) { - sess := NewSession(sysPrompt) +func FormatSubagentResult(answer, ref string, failed bool) string { + if ref == "" { + return answer + } + if failed { + if answer == "" { + return "Subagent reference (failed): " + ref + } + return "Subagent reference (failed): " + ref + "\n\nFinal answer:\n" + answer + } + return "Subagent reference: " + ref + "\n\nFinal answer:\n" + answer +} + +// RunSubAgentWithSession continues an existing sub-agent session with prompt and +// returns the latest final assistant answer. Fresh sub-agents pass a newly-created +// session; continued sub-agents pass a loaded transcript session. +func RunSubAgentWithSession(ctx context.Context, prov provider.Provider, reg *tool.Registry, sess *Session, prompt string, opts Options, sink event.Sink) (string, error) { + if sess == nil { + return "", fmt.Errorf("sub-agent session is nil") + } sub := New(prov, reg, sess, opts, sink) if err := sub.Run(ctx, prompt); err != nil { return "", fmt.Errorf("sub-agent: %w", err) diff --git a/internal/agent/task_test.go b/internal/agent/task_test.go index 66696d65c..42ff5c167 100644 --- a/internal/agent/task_test.go +++ b/internal/agent/task_test.go @@ -10,21 +10,26 @@ import ( "reasonix/internal/tool" ) +func testTaskContext() context.Context { + return WithParentSession(context.Background(), "parent-session") +} + // TestTaskToolReturnsSubAgentFinalAnswer runs a task against a mock provider -// that emits a single text turn, and verifies the tool returns exactly that -// text — sub-agent intermediate state isn't supposed to leak. +// that emits a single text turn, and verifies the tool returns that text with a +// transcript reference — sub-agent intermediate state isn't supposed to leak. func TestTaskToolReturnsSubAgentFinalAnswer(t *testing.T) { sub := &mockProvider{name: "sub", chunks: []provider.Chunk{ {Type: provider.ChunkText, Text: "found 3 callers of Foo"}, {Type: provider.ChunkDone}, }} parentReg := tool.NewRegistry() - task := NewTaskTool(sub, nil, parentReg, 20, 0, 0, 0, 0, 0.0, "", "test-sys-prompt", nil, "", "", nil) + task := newTestTaskTool(t, sub, parentReg, "test-sys-prompt", "", "", nil) - out, err := task.Execute(context.Background(), []byte(`{"prompt":"find callers of Foo"}`)) + out, err := task.Execute(testTaskContext(), []byte(`{"prompt":"find callers of Foo"}`)) if err != nil { t.Fatalf("Execute: %v", err) } + _ = subagentRefFromOutput(t, out) if !strings.Contains(out, "found 3 callers of Foo") { t.Errorf("got %q, want sub-agent final answer", out) } @@ -52,13 +57,13 @@ func TestTaskToolFiltersTools(t *testing.T) { parentReg.Add(fakeTool{name: "read_file", readOnly: true}) parentReg.Add(fakeTool{name: "write_file", readOnly: false}) parentReg.Add(fakeTool{name: "bash", readOnly: false}) - task := NewTaskTool(sub, nil, parentReg, 20, 0, 0, 0, 0, 0.0, "", "sys", nil, "", "", nil) + task := newTestTaskTool(t, sub, parentReg, "sys", "", "", nil) parentReg.Add(task) // simulate the wiring in cli.setup parentReg.Add(fakeTool{name: "run_skill", readOnly: false}) parentReg.Add(fakeTool{name: "research", readOnly: false}) args := []byte(`{"prompt":"x","tools":["read_file","task","write_file","run_skill","research"]}`) - if _, err := task.Execute(context.Background(), args); err != nil { + if _, err := task.Execute(testTaskContext(), args); err != nil { t.Fatalf("Execute: %v", err) } // The sub-agent's tool schemas should reflect the whitelist minus meta-tools. @@ -81,7 +86,7 @@ func TestTaskToolDefaultsToParentToolsWithoutMetaTools(t *testing.T) { parentReg := tool.NewRegistry() parentReg.Add(fakeTool{name: "read_file", readOnly: true}) parentReg.Add(fakeTool{name: "grep", readOnly: true}) - task := NewTaskTool(sub, nil, parentReg, 20, 0, 0, 0, 0, 0.0, "", "sys", nil, "", "", nil) + task := newTestTaskTool(t, sub, parentReg, "sys", "", "", nil) parentReg.Add(task) parentReg.Add(fakeTool{name: "run_skill", readOnly: false}) parentReg.Add(fakeTool{name: "explore", readOnly: false}) @@ -90,7 +95,7 @@ func TestTaskToolDefaultsToParentToolsWithoutMetaTools(t *testing.T) { parentReg.Add(fakeTool{name: "security_review", readOnly: false}) parentReg.Add(fakeTool{name: "remember", readOnly: false}) - if _, err := task.Execute(context.Background(), []byte(`{"prompt":"x"}`)); err != nil { + if _, err := task.Execute(testTaskContext(), []byte(`{"prompt":"x"}`)); err != nil { t.Fatalf("Execute: %v", err) } got := map[string]bool{} @@ -113,13 +118,13 @@ func TestTaskToolUsesConfiguredProfileForExecution(t *testing.T) { {Type: provider.ChunkDone}, }} var gotModel, gotEffort string - task := NewTaskTool(parent, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0.0, "", "sys", nil, "deepseek-pro", "max", - func(model, effort string) (provider.Provider, *provider.Pricing, int, error) { - gotModel, gotEffort = model, effort - return resolved, nil, 0, nil - }) + resolve := func(model, effort string) (provider.Provider, *provider.Pricing, int, error) { + gotModel, gotEffort = model, effort + return resolved, nil, 0, nil + } + task := newTestTaskTool(t, parent, tool.NewRegistry(), "sys", "deepseek-pro", "max", resolve) - out, err := task.Execute(context.Background(), []byte(`{"prompt":"x"}`)) + out, err := task.Execute(testTaskContext(), []byte(`{"prompt":"x"}`)) if err != nil { t.Fatalf("Execute: %v", err) } @@ -136,13 +141,195 @@ func TestTaskToolReturnsProfileResolutionErrors(t *testing.T) { {Type: provider.ChunkText, Text: "parent answer"}, {Type: provider.ChunkDone}, }} - task := NewTaskTool(parent, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0.0, "", "sys", nil, "", "", - func(string, string) (provider.Provider, *provider.Pricing, int, error) { - return nil, nil, 0, errors.New("bad effort") - }) + resolve := func(string, string) (provider.Provider, *provider.Pricing, int, error) { + return nil, nil, 0, errors.New("bad effort") + } + task := newTestTaskTool(t, parent, tool.NewRegistry(), "sys", "", "", resolve) - _, err := task.Execute(context.Background(), []byte(`{"prompt":"x","effort":"turbo"}`)) + _, err := task.Execute(testTaskContext(), []byte(`{"prompt":"x","effort":"turbo"}`)) if err == nil || !strings.Contains(err.Error(), "bad effort") { t.Fatalf("Execute error = %v, want profile resolution error", err) } } + +func TestTaskToolRequiresTranscriptStore(t *testing.T) { + sub := &mockProvider{name: "sub", chunks: []provider.Chunk{ + {Type: provider.ChunkText, Text: "answer"}, + {Type: provider.ChunkDone}, + }} + task := NewTaskTool(sub, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0.0, "", "sys", nil, "", "", nil) + + _, err := task.Execute(testTaskContext(), []byte(`{"prompt":"x"}`)) + if err == nil || !strings.Contains(err.Error(), "transcript store is required") { + t.Fatalf("Execute error = %v, want transcript store requirement", err) + } +} + +// TestTaskToolRunsEphemerallyWithoutParentSession mirrors headless `reasonix run`: +// the store is wired but the context carries no parent session, so the sub-agent +// must run without persistence and return its plain answer (no transcript ref). +func TestTaskToolRunsEphemerallyWithoutParentSession(t *testing.T) { + sub := &mockProvider{name: "sub", chunks: []provider.Chunk{ + {Type: provider.ChunkText, Text: "headless answer"}, + {Type: provider.ChunkDone}, + }} + task := newTestTaskTool(t, sub, tool.NewRegistry(), "sys", "", "", nil) + + out, err := task.Execute(context.Background(), []byte(`{"prompt":"x"}`)) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(out, "headless answer") { + t.Fatalf("got %q, want sub-agent final answer", out) + } + if strings.Contains(out, "Subagent reference") { + t.Fatalf("ephemeral run should not emit a transcript reference: %q", out) + } +} + +func TestTaskToolRejectsContinuationWithoutParentSession(t *testing.T) { + sub := &mockProvider{name: "sub", chunks: []provider.Chunk{ + {Type: provider.ChunkText, Text: "answer"}, + {Type: provider.ChunkDone}, + }} + task := newTestTaskTool(t, sub, tool.NewRegistry(), "sys", "", "", nil) + + _, err := task.Execute(context.Background(), []byte(`{"prompt":"x","continue_from":"sa_whatever"}`)) + if err == nil || !strings.Contains(err.Error(), "persisted session") { + t.Fatalf("Execute error = %v, want persisted-session requirement", err) + } +} + +func TestTaskToolPersistsAndContinuesTranscript(t *testing.T) { + sub := &mockProvider{name: "sub", streams: [][]provider.Chunk{ + { + {Type: provider.ChunkText, Text: "first answer"}, + {Type: provider.ChunkDone}, + }, + { + {Type: provider.ChunkText, Text: "second answer"}, + {Type: provider.ChunkDone}, + }, + }} + reg := tool.NewRegistry() + reg.Add(fakeTool{name: "read_file", readOnly: true}) + store := NewSubagentStore(t.TempDir()) + task := newTestTaskTool(t, sub, reg, "sys", "", "", nil). + WithTranscripts(store, t.TempDir(), "base-model", "base-effort") + + first, err := task.Execute(testTaskContext(), []byte(`{"prompt":"first task"}`)) + if err != nil { + t.Fatalf("first Execute: %v", err) + } + ref := subagentRefFromOutput(t, first) + meta, err := store.LoadMeta(ref) + if err != nil { + t.Fatalf("LoadMeta: %v", err) + } + if meta.ParentSession != "parent-session" { + t.Fatalf("parent session = %q, want parent-session", meta.ParentSession) + } + if !strings.Contains(first, "first answer") { + t.Fatalf("first output = %q, want answer", first) + } + + second, err := task.Execute(testTaskContext(), []byte(`{"prompt":"second task","continue_from":"`+ref+`"}`)) + if err != nil { + t.Fatalf("second Execute: %v", err) + } + if !strings.Contains(second, "second answer") { + t.Fatalf("second output = %q, want answer", second) + } + if len(sub.requests) != 2 { + t.Fatalf("provider requests = %d, want 2", len(sub.requests)) + } + msgs := sub.requests[1].Messages + if len(msgs) < 4 { + t.Fatalf("continued request messages = %+v, want prior transcript plus new task", msgs) + } + if msgs[1].Content != "first task" || msgs[2].Content != "first answer" || lastUser(sub.requests[1]) != "second task" { + t.Fatalf("continued request messages = %+v, want first task/answer then second task", msgs) + } +} + +func TestTaskToolFailedForegroundContinuationPersistsAndRejectsReuse(t *testing.T) { + sub := &mockProvider{name: "sub", streams: [][]provider.Chunk{ + { + {Type: provider.ChunkText, Text: "first answer"}, + {Type: provider.ChunkDone}, + }, + { + {Type: provider.ChunkError, Err: errors.New("provider failed")}, + }, + }} + store := NewSubagentStore(t.TempDir()) + reg := tool.NewRegistry() + reg.Add(fakeTool{name: "read_file", readOnly: true}) + task := NewTaskTool(sub, nil, reg, 20, 0, 0, 0, 0, 0.0, "", "sys", nil, "", "", nil). + WithTranscripts(store, t.TempDir(), "base-model", "base-effort") + + first, err := task.Execute(testTaskContext(), []byte(`{"prompt":"first task"}`)) + if err != nil { + t.Fatalf("first Execute: %v", err) + } + ref := subagentRefFromOutput(t, first) + + _, err = task.Execute(testTaskContext(), []byte(`{"prompt":"second task","continue_from":"`+ref+`"}`)) + if err == nil || !strings.Contains(err.Error(), "provider failed") { + t.Fatalf("second Execute error = %v, want provider failure", err) + } + meta, err := store.LoadMeta(ref) + if err != nil { + t.Fatalf("LoadMeta: %v", err) + } + if meta.Status != SubagentFailed { + t.Fatalf("status = %q, want failed", meta.Status) + } + loaded, err := LoadSession(store.sessionPath(ref)) + if err != nil { + t.Fatalf("LoadSession: %v", err) + } + msgs := loaded.Snapshot() + if len(msgs) != 4 || msgs[1].Content != "first task" || msgs[2].Content != "first answer" || msgs[3].Content != "second task" { + t.Fatalf("failed continuation transcript = %+v, want first task/answer plus second task", msgs) + } + if _, err := task.Execute(testTaskContext(), []byte(`{"prompt":"third task","continue_from":"`+ref+`"}`)); err == nil || !strings.Contains(err.Error(), "failed and cannot be continued") { + t.Fatalf("reuse error = %v, want failed ref rejection", err) + } +} + +func TestTaskToolRejectsMismatchedContinuationProfile(t *testing.T) { + sub := &mockProvider{name: "sub", chunks: []provider.Chunk{ + {Type: provider.ChunkText, Text: "answer"}, + {Type: provider.ChunkDone}, + }} + task := newTestTaskTool(t, sub, tool.NewRegistry(), "sys", "", "", nil). + WithTranscripts(NewSubagentStore(t.TempDir()), t.TempDir(), "base-model", "") + + out, err := task.Execute(testTaskContext(), []byte(`{"prompt":"first task"}`)) + if err != nil { + t.Fatalf("first Execute: %v", err) + } + ref := subagentRefFromOutput(t, out) + _, err = task.Execute(testTaskContext(), []byte(`{"prompt":"second task","continue_from":"`+ref+`","model":"other-model"}`)) + if err == nil || !strings.Contains(err.Error(), "model/effort") { + t.Fatalf("mismatched model error = %v, want compatibility failure", err) + } +} + +func subagentRefFromOutput(t *testing.T, out string) string { + t.Helper() + for _, line := range strings.Split(out, "\n") { + if strings.HasPrefix(line, "Subagent reference: ") { + return strings.TrimSpace(strings.TrimPrefix(line, "Subagent reference: ")) + } + } + t.Fatalf("no subagent reference in output:\n%s", out) + return "" +} + +func newTestTaskTool(t *testing.T, prov provider.Provider, reg *tool.Registry, sysPrompt, subagentModel, subagentEffort string, resolve func(string, string) (provider.Provider, *provider.Pricing, int, error)) *TaskTool { + t.Helper() + return NewTaskTool(prov, nil, reg, 20, 0, 0, 0, 0, 0.0, "", sysPrompt, nil, subagentModel, subagentEffort, resolve). + WithTranscripts(NewSubagentStore(t.TempDir()), t.TempDir(), "base-model", "base-effort") +} diff --git a/internal/boot/boot.go b/internal/boot/boot.go index 3116bfb09..ba89cfd74 100644 --- a/internal/boot/boot.go +++ b/internal/boot/boot.go @@ -375,6 +375,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { if opts.MaxSteps > 0 { maxSteps = opts.MaxSteps } + subagentStore := newSubagentStore(config.SessionDir()) // Permission policy gates every tool call. The headless gate (no Approver) // resolves "ask" to allow — preserving `reasonix run` autonomy — while deny @@ -431,12 +432,17 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { } return p, me.Price, me.ContextWindow, nil } + subagentIdentity := func(modelRef, effort string) (string, string) { + return subagentEffectiveIdentity(cfg, modelName, entry, modelRef, effort) + } taskModel := firstNonEmpty(cfg.Agent.SubagentModels["task"], cfg.Agent.SubagentModel) taskEffort := firstNonEmpty(cfg.Agent.SubagentEfforts["task"], cfg.Agent.SubagentEffort) reg.Add(agent.NewTaskTool(execProv, entry.Price, reg, maxSteps, entry.ContextWindow, cfg.Agent.SoftCompactRatio, cfg.Agent.CompactRatio, cfg.Agent.CompactForceRatio, cfg.Agent.Temperature, config.ArchiveDir(), "", headlessGate, - taskModel, taskEffort, resolveSubagentProvider)) + taskModel, taskEffort, resolveSubagentProvider). + WithTranscripts(subagentStore, root, modelName, entry.Effort). + WithTranscriptIdentityResolver(subagentIdentity)) // The `remember` tool lets the model persist durable facts to the project's // auto-memory store; `forget` prunes ones that turn out wrong. The saved index @@ -456,7 +462,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { // as system prompt, a tool set scoped to the skill's allowed-tools (minus the // task/skill meta-tools, to bar recursion), and an optional per-skill model. // Its tool activity nests under the invoking call, like `task`. - skillRunner := func(sctx context.Context, sk skill.Skill, task string) (string, error) { + skillRunner := func(sctx context.Context, sk skill.Skill, task string, runOpts skill.SubagentRunOptions) (string, error) { prov, price, ctxWin := execProv, entry.Price, entry.ContextWindow modelRef := subagentModelRef(cfg, sk) effortRef := subagentEffortRef(cfg, sk) @@ -468,13 +474,56 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { prov, price, ctxWin = p, pr, cw } subReg := agent.FilterRegistry(reg, sk.AllowedTools, agent.SubagentMetaTools()...) + continueFrom, forkFrom := strings.TrimSpace(runOpts.ContinueFrom), strings.TrimSpace(runOpts.ForkFrom) + if continueFrom != "" && forkFrom != "" { + return "", fmt.Errorf("continue_from and fork_from are mutually exclusive") + } + parentID, _, _, _ := agent.CallContext(sctx) + parentSession := agent.ParentSession(sctx) + var run *agent.SubagentRun + if subagentStore == nil || parentSession == "" { + // Headless runs (e.g. `reasonix run`) have no persistent session to + // own a transcript. Run the skill sub-agent ephemerally, as before + // persisted transcripts existed, instead of failing. Continuation and + // fork need a persisted owner, so they error here. + if continueFrom != "" || forkFrom != "" { + return "", fmt.Errorf("continue_from/fork_from require a persisted session; none is active in this run") + } + run = agent.EphemeralSubagentRun(sk.Body) + } else { + identityModel, identityEffort := subagentIdentity(modelRef, effortRef) + spec := agent.SubagentSpec{ + Kind: "skill", + Name: sk.Name, + WorkspaceRoot: root, + ParentSession: parentSession, + ParentToolCallID: parentID, + SystemPrompt: sk.Body, + Registry: subReg, + Model: identityModel, + Effort: identityEffort, + } + var prepErr error + switch { + case continueFrom != "": + run, prepErr = subagentStore.PrepareContinue(continueFrom, spec) + case forkFrom != "": + run, prepErr = subagentStore.PrepareFork(forkFrom, spec) + default: + run, prepErr = subagentStore.PrepareFresh(spec) + } + if prepErr != nil { + return "", prepErr + } + } + defer run.Release() steps := maxSteps if steps > 0 { if steps /= 2; steps < 5 { steps = 5 } } - return agent.RunSubAgent(sctx, prov, subReg, sk.Body, task, agent.Options{ + answer, err := agent.RunSubAgentWithSession(sctx, prov, subReg, run.Session, task, agent.Options{ MaxSteps: steps, Temperature: cfg.Agent.Temperature, Pricing: price, @@ -482,6 +531,13 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { ContextWindow: ctxWin, ArchiveDir: config.ArchiveDir(), }, agent.NestedSink(sctx, event.Discard)) + if err != nil { + return "", errors.Join(err, subagentStore.SaveFailed(run)) + } + if err := subagentStore.SaveCompleted(run); err != nil { + return "", errors.Join(err, subagentStore.SaveFailed(run)) + } + return agent.FormatSubagentResult(answer, run.Ref, false), nil } skillProfile := func(sk skill.Skill) *event.Profile { model, effort := subagentModelRef(cfg, sk), subagentEffortRef(cfg, sk) @@ -871,6 +927,51 @@ func isGitMarker(path string) bool { return err == nil && (fi.IsDir() || fi.Mode().IsRegular()) } +func newSubagentStore(sessionDir string) *agent.SubagentStore { + sessionDir = strings.TrimSpace(sessionDir) + if sessionDir == "" { + return nil + } + return agent.NewSubagentStore(filepath.Join(sessionDir, "subagents")) +} + +func subagentEffectiveIdentity(cfg *config.Config, baseModelRef string, base *config.ProviderEntry, modelRef, effort string) (string, string) { + var entry config.ProviderEntry + if base != nil { + entry = *base + } + ref := strings.TrimSpace(modelRef) + if ref == "" { + ref = strings.TrimSpace(baseModelRef) + } + if cfg != nil && ref != "" { + if resolved, ok := cfg.ResolveModel(ref); ok { + entry = *resolved + } else if strings.TrimSpace(modelRef) != "" { + entry.Model = ref + } + } else if strings.TrimSpace(modelRef) != "" { + entry.Model = strings.TrimSpace(modelRef) + } + if rawEffort := strings.TrimSpace(effort); rawEffort != "" { + if normalized, err := config.NormalizeEffort(&entry, rawEffort); err == nil { + entry.Effort = normalized + } else { + entry.Effort = rawEffort + } + } + modelID := strings.TrimSpace(entry.Name) + model := strings.TrimSpace(entry.Model) + if modelID != "" && model != "" { + modelID += "/" + model + } else if model != "" { + modelID = model + } else if modelID == "" { + modelID = ref + } + return modelID, strings.TrimSpace(config.EffectiveEffort(&entry)) +} + // NewProvider builds a provider.Provider from a configured entry. Exported so // custom assemblers (e.g. the ACP per-session factory) can reuse it without // going through the full Build. diff --git a/internal/boot/boot_test.go b/internal/boot/boot_test.go index 0dea91825..1809eba44 100644 --- a/internal/boot/boot_test.go +++ b/internal/boot/boot_test.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -13,9 +14,11 @@ import ( "path/filepath" "runtime" "strings" + "sync" "testing" "time" + "reasonix/internal/agent" "reasonix/internal/config" "reasonix/internal/event" "reasonix/internal/netclient" @@ -81,6 +84,288 @@ api_key_env = "REASONIX_TEST_KEY_UNSET" } } +func TestBuildSubagentSkillFailedContinuationPersistsTranscript(t *testing.T) { + isolateConfigHome(t) + dir := robustTempDir(t) + t.Chdir(dir) + + registerBootSubagentTestProvider() + prov := &bootSubagentTestProvider{} + setBootSubagentTestProvider(t, prov) + writeFile(t, dir, "reasonix.toml", ` +default_model = "test-model" + +[codegraph] +enabled = false + +[agent] +system_prompt = "BASE" + +[[providers]] +name = "test-model" +kind = "boot-subagent-test" +model = "x" +`) + + ctrl, err := Build(context.Background(), Options{Sink: event.Discard}) + if err != nil { + t.Fatalf("Build: %v", err) + } + defer ctrl.Close() + sessionPath := agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label()) + ctrl.SetSessionPath(sessionPath) + + if err := ctrl.Run(context.Background(), "first review"); err != nil { + t.Fatalf("first Run: %v", err) + } + ref := subagentRefFromHistory(t, ctrl.History()) + prov.setContinueRef(ref) + + if err := ctrl.Run(context.Background(), "continue review"); err != nil { + t.Fatalf("second Run: %v", err) + } + store := agent.NewSubagentStore(filepath.Join(config.SessionDir(), "subagents")) + meta, err := store.LoadMeta(ref) + if err != nil { + t.Fatalf("LoadMeta: %v", err) + } + if meta.Status != agent.SubagentFailed { + t.Fatalf("status = %q, want failed", meta.Status) + } + if meta.ParentSession != agent.BranchID(sessionPath) { + t.Fatalf("parent session = %q, want %q", meta.ParentSession, agent.BranchID(sessionPath)) + } + sess, err := agent.LoadSession(filepath.Join(config.SessionDir(), "subagents", ref+".jsonl")) + if err != nil { + t.Fatalf("LoadSession: %v", err) + } + msgs := sess.Snapshot() + if len(msgs) != 4 || msgs[1].Content != "first skill task" || msgs[2].Content != "first skill answer" || msgs[3].Content != "second skill task" { + t.Fatalf("failed skill transcript = %+v, want first task/answer plus second task", msgs) + } +} + +const bootSubagentTestProviderKind = "boot-subagent-test" + +var ( + bootSubagentTestProviderOnce sync.Once + bootSubagentTestProviderCurrent *bootSubagentTestProvider + bootSubagentTestProviderMu sync.Mutex +) + +func registerBootSubagentTestProvider() { + bootSubagentTestProviderOnce.Do(func() { + provider.Register(bootSubagentTestProviderKind, func(provider.Config) (provider.Provider, error) { + bootSubagentTestProviderMu.Lock() + defer bootSubagentTestProviderMu.Unlock() + if bootSubagentTestProviderCurrent == nil { + return nil, errors.New("boot subagent test provider is not installed") + } + return bootSubagentTestProviderCurrent, nil + }) + }) +} + +func setBootSubagentTestProvider(t *testing.T, p *bootSubagentTestProvider) { + t.Helper() + bootSubagentTestProviderMu.Lock() + bootSubagentTestProviderCurrent = p + bootSubagentTestProviderMu.Unlock() + t.Cleanup(func() { + bootSubagentTestProviderMu.Lock() + if bootSubagentTestProviderCurrent == p { + bootSubagentTestProviderCurrent = nil + } + bootSubagentTestProviderMu.Unlock() + }) +} + +type bootSubagentTestProvider struct { + mu sync.Mutex + calls int + continueRef string +} + +func (p *bootSubagentTestProvider) Name() string { return "boot-subagent-test" } + +func (p *bootSubagentTestProvider) setContinueRef(ref string) { + p.mu.Lock() + defer p.mu.Unlock() + p.continueRef = ref +} + +func (p *bootSubagentTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) { + p.mu.Lock() + call := p.calls + p.calls++ + ref := p.continueRef + p.mu.Unlock() + + var chunks []provider.Chunk + switch call { + case 0: + chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "review-1", Name: "review", Arguments: `{"task":"first skill task"}`}}} + case 1: + chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "first skill answer"}, {Type: provider.ChunkDone}} + case 2: + chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "parent first done"}, {Type: provider.ChunkDone}} + case 3: + args, _ := json.Marshal(map[string]string{"task": "second skill task", "continue_from": ref}) + chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "review-2", Name: "review", Arguments: string(args)}}} + case 4: + chunks = []provider.Chunk{{Type: provider.ChunkError, Err: errors.New("subagent skill failed")}} + case 5: + chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "parent second done"}, {Type: provider.ChunkDone}} + default: + chunks = []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf("unexpected provider call %d", call)}} + } + ch := make(chan provider.Chunk, len(chunks)) + for _, chunk := range chunks { + ch <- chunk + } + close(ch) + return ch, nil +} + +func subagentRefFromHistory(t *testing.T, msgs []provider.Message) string { + t.Helper() + for _, msg := range msgs { + if msg.Role != provider.RoleTool { + continue + } + for _, line := range strings.Split(msg.Content, "\n") { + if strings.HasPrefix(line, "Subagent reference: ") { + return strings.TrimSpace(strings.TrimPrefix(line, "Subagent reference: ")) + } + } + } + t.Fatalf("no subagent reference in history: %+v", msgs) + return "" +} + +// TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath reproduces headless +// `reasonix run`: a controller built via Build with NO SetSessionPath (exactly +// what internal/cli.runAgent does) must still be able to run a `task` sub-agent. +// Before the ephemeral fallback this failed with "parent session is required". +func TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath(t *testing.T) { + isolateConfigHome(t) + dir := robustTempDir(t) + t.Chdir(dir) + + registerHeadlessTaskTestProvider() + prov := &headlessTaskTestProvider{} + setHeadlessTaskTestProvider(t, prov) + writeFile(t, dir, "reasonix.toml", ` +default_model = "test-model" + +[codegraph] +enabled = false + +[agent] +system_prompt = "BASE" + +[[providers]] +name = "test-model" +kind = "boot-headless-test" +model = "x" +`) + + ctrl, err := Build(context.Background(), Options{Sink: event.Discard}) + if err != nil { + t.Fatalf("Build: %v", err) + } + defer ctrl.Close() + + // Deliberately NOT calling SetSessionPath — this is the headless run path. + if err := ctrl.Run(context.Background(), "use a task subagent"); err != nil { + t.Fatalf("Run: %v", err) + } + if got := ctrl.SessionPath(); got != "" { + t.Fatalf("headless run should keep an empty session path, got %q", got) + } + + var toolContent string + for _, msg := range ctrl.History() { + if msg.Role == provider.RoleTool { + toolContent += "\n" + msg.Content + } + } + if strings.Contains(toolContent, "parent session is required") { + t.Fatalf("task subagent failed in headless run mode: %s", toolContent) + } + if !strings.Contains(toolContent, "subagent answer") { + t.Fatalf("task tool result = %q, want sub-agent answer", toolContent) + } + if strings.Contains(toolContent, "Subagent reference") { + t.Fatalf("ephemeral headless run should not persist a transcript reference: %s", toolContent) + } +} + +const headlessTaskTestProviderKind = "boot-headless-test" + +var ( + headlessTaskTestProviderOnce sync.Once + headlessTaskTestProviderCurrent *headlessTaskTestProvider + headlessTaskTestProviderMu sync.Mutex +) + +func registerHeadlessTaskTestProvider() { + headlessTaskTestProviderOnce.Do(func() { + provider.Register(headlessTaskTestProviderKind, func(provider.Config) (provider.Provider, error) { + headlessTaskTestProviderMu.Lock() + defer headlessTaskTestProviderMu.Unlock() + if headlessTaskTestProviderCurrent == nil { + return nil, errors.New("headless task test provider is not installed") + } + return headlessTaskTestProviderCurrent, nil + }) + }) +} + +func setHeadlessTaskTestProvider(t *testing.T, p *headlessTaskTestProvider) { + t.Helper() + headlessTaskTestProviderMu.Lock() + headlessTaskTestProviderCurrent = p + headlessTaskTestProviderMu.Unlock() + t.Cleanup(func() { + headlessTaskTestProviderMu.Lock() + if headlessTaskTestProviderCurrent == p { + headlessTaskTestProviderCurrent = nil + } + headlessTaskTestProviderMu.Unlock() + }) +} + +type headlessTaskTestProvider struct { + mu sync.Mutex + calls int +} + +func (p *headlessTaskTestProvider) Name() string { return "boot-headless-test" } + +func (p *headlessTaskTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) { + p.mu.Lock() + call := p.calls + p.calls++ + p.mu.Unlock() + + var chunks []provider.Chunk + switch call { + case 0: + chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "task-1", Name: "task", Arguments: `{"prompt":"find callers"}`}}} + case 1: + chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "subagent answer"}, {Type: provider.ChunkDone}} + default: + chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "parent done"}, {Type: provider.ChunkDone}} + } + ch := make(chan provider.Chunk, len(chunks)) + for _, chunk := range chunks { + ch <- chunk + } + close(ch) + return ch, nil +} + func TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) { var gotReq map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/boot/subagent_model_test.go b/internal/boot/subagent_model_test.go index b2f9f8d65..e120a5a3d 100644 --- a/internal/boot/subagent_model_test.go +++ b/internal/boot/subagent_model_test.go @@ -89,3 +89,38 @@ func TestSubagentEffortRefAcceptsToolNameAliases(t *testing.T) { t.Fatalf("security_review alias should configure security-review effort, got %q", got) } } + +func TestSubagentEffectiveIdentityUsesResolvedModelAndEffort(t *testing.T) { + cfg := config.Default() + cfg.Providers = []config.ProviderEntry{{ + Name: "custom", + Kind: "openai", + Models: []string{"alpha", "beta"}, + Default: "beta", + SupportedEfforts: []string{"low", "high"}, + DefaultEffort: "high", + }} + base, ok := cfg.ResolveModel("custom") + if !ok { + t.Fatal("custom provider should resolve") + } + + model, effort := subagentEffectiveIdentity(cfg, "custom", base, "", "") + if model != "custom/beta" || effort != "high" { + t.Fatalf("identity = %q/%q, want custom/beta/high", model, effort) + } + + model, effort = subagentEffectiveIdentity(cfg, "custom", base, "alpha", "low") + if model != "custom/alpha" || effort != "low" { + t.Fatalf("override identity = %q/%q, want custom/alpha/low", model, effort) + } +} + +func TestNewSubagentStoreRequiresSessionDir(t *testing.T) { + if got := newSubagentStore(""); got != nil { + t.Fatalf("empty session dir should disable subagent store, got %#v", got) + } + if got := newSubagentStore(t.TempDir()); got == nil { + t.Fatal("non-empty session dir should create subagent store") + } +} diff --git a/internal/cli/review.go b/internal/cli/review.go index 58a90513b..f91841db9 100644 --- a/internal/cli/review.go +++ b/internal/cli/review.go @@ -90,7 +90,7 @@ func reviewCommand(args []string) int { // 7. Run the review subagent. ctx := context.Background() - result, err := agent.RunSubAgent(ctx, prov, reg, reviewSk.Body, task, agent.Options{ + result, err := agent.RunSubAgentWithSession(ctx, prov, reg, agent.NewSession(reviewSk.Body), task, agent.Options{ MaxSteps: 12, Temperature: cfg.Agent.Temperature, Pricing: entry.Price, diff --git a/internal/control/controller.go b/internal/control/controller.go index 24a132d6b..d611d3051 100644 --- a/internal/control/controller.go +++ b/internal/control/controller.go @@ -443,6 +443,7 @@ func (c *Controller) runTurnWithRaw(ctx context.Context, input, raw string) erro func (c *Controller) runTurnWithRawDisplay(ctx context.Context, input, raw, display string) error { c.maybeSessionStart(ctx) c.maybeAutoPlan(ctx, raw) + ctx = agent.WithParentSession(ctx, c.parentSessionID()) input = c.Compose(input) startMessages := c.messageCount() defer c.snapshotActivityIfChanged(startMessages) @@ -781,6 +782,7 @@ func (c *Controller) notice(text string) { // just needs the exit status — no TurnDone event, no cancel bookkeeping. func (c *Controller) Run(ctx context.Context, input string) error { c.maybeSessionStart(ctx) + ctx = agent.WithParentSession(ctx, c.parentSessionID()) startMessages := c.messageCount() defer c.snapshotActivityIfChanged(startMessages) if c.hooks.Enabled() { @@ -1405,6 +1407,10 @@ func (c *Controller) SessionPath() string { return c.sessionPath } +func (c *Controller) parentSessionID() string { + return agent.BranchID(c.SessionPath()) +} + // History returns the executor's current message log (for repopulating a // resumed frontend's view). func (c *Controller) History() []provider.Message { diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 3e211833c..a71b2f2a7 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -888,6 +888,10 @@ func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } + if err := agent.DeleteSubagentsByParent(dir, agent.BranchID(abs)); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } w.WriteHeader(http.StatusNoContent) } diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go index 8fa789bf1..b3d44b6ac 100644 --- a/internal/serve/serve_test.go +++ b/internal/serve/serve_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "reasonix/internal/agent" "reasonix/internal/config" "reasonix/internal/control" "reasonix/internal/provider" @@ -282,6 +283,8 @@ func TestDeleteSessionRequiresSessionNameInsideSessionDir(t *testing.T) { t.Fatal(err) } } + ref := "sa_20260102_030405_000000000_aabbccddeeff" + writeServeSubagentArtifact(t, dir, ref, agent.BranchID(old)) sibling := dir + "-other" if err := os.MkdirAll(sibling, 0o755); err != nil { t.Fatal(err) @@ -322,6 +325,36 @@ func TestDeleteSessionRequiresSessionNameInsideSessionDir(t *testing.T) { if _, err := os.Stat(old); !os.IsNotExist(err) { t.Fatalf("old session still exists or stat failed unexpectedly: %v", err) } + if _, err := os.Stat(filepath.Join(dir, "subagents", ref+".jsonl")); !os.IsNotExist(err) { + t.Fatalf("old session subagent jsonl still exists or stat failed unexpectedly: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "subagents", ref+".meta.json")); !os.IsNotExist(err) { + t.Fatalf("old session subagent meta still exists or stat failed unexpectedly: %v", err) + } +} + +func writeServeSubagentArtifact(t *testing.T, dir, ref, parentSession string) { + t.Helper() + subagentDir := filepath.Join(dir, "subagents") + if err := os.MkdirAll(subagentDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(subagentDir, ref+".jsonl"), []byte(`{"role":"user","content":"sub"}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(agent.SubagentMeta{ + Ref: ref, + Status: agent.SubagentCompleted, + Kind: "task", + Name: "task", + ParentSession: parentSession, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(subagentDir, ref+".meta.json"), data, 0o644); err != nil { + t.Fatal(err) + } } func TestServeSubmitMalformedJSON(t *testing.T) { diff --git a/internal/skill/tools.go b/internal/skill/tools.go index 13fed81f2..57ce5630b 100644 --- a/internal/skill/tools.go +++ b/internal/skill/tools.go @@ -17,7 +17,12 @@ import ( // the final answer. boot wires this over the agent's sub-agent machinery; nil // means subagent skills are unavailable in this session (they error rather than // silently inlining, which would lose the isolation the author asked for). -type SubagentRunner func(ctx context.Context, sk Skill, task string) (string, error) +type SubagentRunOptions struct { + ContinueFrom string + ForkFrom string +} + +type SubagentRunner func(ctx context.Context, sk Skill, task string, opts SubagentRunOptions) (string, error) // ProfileResolver returns the model/effort profile a subagent skill will use. // It is optional; without one, skill frontmatter still supplies display metadata. @@ -61,7 +66,9 @@ func (*runSkillTool) Schema() json.RawMessage { "type":"object", "properties":{ "name":{"type":"string","description":"Skill identifier as it appears in the pinned Skills index (e.g. 'explore', 'review'). Case-sensitive. Just the identifier, not the [🧬 subagent] tag."}, - "arguments":{"type":"string","description":"Free-form arguments. For inline skills: appended as an 'Arguments:' line; the skill's own instructions decide how to use them. For subagent skills: REQUIRED — becomes the entire task the subagent receives."} + "arguments":{"type":"string","description":"Free-form arguments. For inline skills: appended as an 'Arguments:' line; the skill's own instructions decide how to use them. For subagent skills: REQUIRED — becomes the entire task the subagent receives."}, + "continue_from":{"type":"string","description":"Optional subagent transcript reference to continue in place. Only valid for runAs=subagent skills."}, + "fork_from":{"type":"string","description":"Optional subagent transcript reference to copy before running. Only valid for runAs=subagent skills; mutually exclusive with continue_from."} }, "required":["name"] }`) @@ -71,6 +78,8 @@ func (t *runSkillTool) Execute(ctx context.Context, args json.RawMessage) (strin var p struct { Name string `json:"name"` Arguments string `json:"arguments"` + Continue string `json:"continue_from"` + Fork string `json:"fork_from"` } if err := json.Unmarshal(args, &p); err != nil { return "", fmt.Errorf("invalid args: %w", err) @@ -84,6 +93,7 @@ func (t *runSkillTool) Execute(ctx context.Context, args json.RawMessage) (strin return "", fmt.Errorf("unknown skill %q — available: %s", name, availableNames(t.store)) } rawArgs := strings.TrimSpace(p.Arguments) + opts := SubagentRunOptions{ContinueFrom: strings.TrimSpace(p.Continue), ForkFrom: strings.TrimSpace(p.Fork)} if sk.RunAs == RunSubagent { if t.runner == nil { @@ -92,7 +102,10 @@ func (t *runSkillTool) Execute(ctx context.Context, args json.RawMessage) (strin if rawArgs == "" { return "", fmt.Errorf("run_skill: skill %q is a subagent and requires 'arguments' — the subagent has no other context, so describe the concrete task", name) } - return t.runner(ctx, sk, rawArgs) + return t.runner(ctx, sk, rawArgs, opts) + } + if opts.ContinueFrom != "" || opts.ForkFrom != "" { + return "", fmt.Errorf("run_skill: continue_from/fork_from are only valid for runAs=subagent skills") } return renderInline(sk, rawArgs), nil } @@ -198,12 +211,14 @@ func (t *subagentSkillTool) Description() string { return t.description } func (t *subagentSkillTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object","properties":{"task":{"type":"string","description":` + - strconv.Quote(t.taskDesc) + `}},"required":["task"]}`) + strconv.Quote(t.taskDesc) + `},"continue_from":{"type":"string","description":"Optional subagent transcript reference to continue in place."},"fork_from":{"type":"string","description":"Optional subagent transcript reference to copy before running. Mutually exclusive with continue_from."}},"required":["task"]}`) } func (t *subagentSkillTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { var p struct { - Task string `json:"task"` + Task string `json:"task"` + Continue string `json:"continue_from"` + Fork string `json:"fork_from"` } if err := json.Unmarshal(args, &p); err != nil { return "", fmt.Errorf("invalid args: %w", err) @@ -224,7 +239,7 @@ func (t *subagentSkillTool) Execute(ctx context.Context, args json.RawMessage) ( if t.runner == nil { return "", fmt.Errorf("%s: no subagent runner is configured in this session", t.toolName) } - return t.runner(ctx, sk, task) + return t.runner(ctx, sk, task, SubagentRunOptions{ContinueFrom: strings.TrimSpace(p.Continue), ForkFrom: strings.TrimSpace(p.Fork)}) } func (t *subagentSkillTool) ResolveProfile(json.RawMessage) *event.Profile { diff --git a/internal/skill/tools_test.go b/internal/skill/tools_test.go index 51e1f967b..3b359175b 100644 --- a/internal/skill/tools_test.go +++ b/internal/skill/tools_test.go @@ -49,7 +49,7 @@ func TestRunSkillSubagentRuns(t *testing.T) { home := t.TempDir() writeSkill(t, home, ".reasonix/skills/dig.md", "---\ndescription: dig\nrunAs: subagent\n---\nbody") var gotTask string - runner := func(_ context.Context, sk Skill, task string) (string, error) { + runner := func(_ context.Context, sk Skill, task string, _ SubagentRunOptions) (string, error) { gotTask = task return "answer from " + sk.Name, nil } @@ -86,7 +86,9 @@ func TestRunSkillSubagentResolvesProfile(t *testing.T) { func TestRunSkillSubagentRequiresArgs(t *testing.T) { home := t.TempDir() writeSkill(t, home, ".reasonix/skills/dig.md", "---\ndescription: dig\nrunAs: subagent\n---\nbody") - runner := func(_ context.Context, _ Skill, _ string) (string, error) { return "x", nil } + runner := func(_ context.Context, _ Skill, _ string, _ SubagentRunOptions) (string, error) { + return "x", nil + } tl := NewRunSkillTool(New(Options{HomeDir: home, DisableBuiltins: true}), runner) if _, err := tl.Execute(context.Background(), json.RawMessage(`{"name":"dig"}`)); err == nil { t.Error("subagent skill should require arguments") @@ -111,7 +113,7 @@ func TestCleanSkillName(t *testing.T) { func TestBuiltinSubagentToolsRunner(t *testing.T) { var ran string - runner := func(_ context.Context, sk Skill, task string) (string, error) { + runner := func(_ context.Context, sk Skill, task string, _ SubagentRunOptions) (string, error) { ran = sk.Name + ":" + task return "ok", nil } @@ -136,6 +138,34 @@ func TestBuiltinSubagentToolsRunner(t *testing.T) { } } +func TestBuiltinSubagentToolsPassContinuationOptions(t *testing.T) { + var got SubagentRunOptions + runner := func(_ context.Context, _ Skill, _ string, opts SubagentRunOptions) (string, error) { + got = opts + return "ok", nil + } + tools := BuiltinSubagentTools(New(Options{HomeDir: t.TempDir()}), runner) + var review interface { + Name() string + Execute(context.Context, json.RawMessage) (string, error) + } + for _, tl := range tools { + if tl.Name() == "review" { + review = tl + break + } + } + if review == nil { + t.Fatal("review wrapper tool not built") + } + if _, err := review.Execute(context.Background(), json.RawMessage(`{"task":"again","continue_from":"sa_prev"}`)); err != nil { + t.Fatalf("execute: %v", err) + } + if got.ContinueFrom != "sa_prev" || got.ForkFrom != "" { + t.Fatalf("continuation opts = %+v, want continue_from sa_prev", got) + } +} + func TestBuiltinSubagentToolResolvesProfile(t *testing.T) { store := New(Options{HomeDir: t.TempDir()}) tools := BuiltinSubagentTools(store, nil, func(sk Skill) *event.Profile { From 51811c1d04a288a829642b45cf8186592a223f0f Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 9 Jun 2026 17:02:45 -0700 Subject: [PATCH 36/64] fix(security): resolve open code-scanning alerts (archive extraction, command injection, untrusted checkout, integer bounds) (#3718) * @ fix(codegraph): resolve symlinks when extracting third-party bundle The lexical tar/zip-slip guard missed the symlink-redirect escape: an in-bounds symlink extracted as a parent component lets a later entry be written through it to land outside the cache dir. Resolve the real parent with EvalSymlinks before validating, and judge a symlink target from its resolved location. Closes the go/unsafe-unzip-symlink + go/zipslip alerts. @ * @ fix(scripts): run gh without a shell in backfill-issue-labels execSync built a shell command string from interpolated values; switch to execFileSync with an argv array so label and issue arguments can never be parsed as shell. Closes the js/command-line-injection alert. @ * @ ci(e2e-bot): gate untrusted PR-head run behind an environment Running PR-head code with the provider secret was guarded only by the author_association check. Add an `e2e-bot` deployment environment (configure required reviewers to force per-run approval) and pin the checkout to the head commit resolved at trigger time, detached, so a mid-run force-push cannot swap in different code. Addresses actions/untrusted-checkout-toctou. @ * @ fix: bound integer conversions flagged by code scanning parseHexColor parses single bytes; parse them as 8-bit unsigned and return int so the per-channel value is provably in range, dropping the int64->int conversions at the call sites. Clamp the Myers maxD against a negative n+m overflow too, so make() can never see a wrapped size. @ --------- Co-authored-by: reasonix --- .github/workflows/e2e-bot.yml | 13 ++++- internal/cli/theme.go | 12 ++--- internal/codegraph/install.go | 59 +++++++++++++++-------- internal/codegraph/install_test.go | 15 +++--- internal/codegraph/symlink_escape_test.go | 40 +++++++++++++++ internal/diff/diff.go | 4 +- scripts/backfill-issue-labels.mjs | 13 ++--- 7 files changed, 114 insertions(+), 42 deletions(-) diff --git a/.github/workflows/e2e-bot.yml b/.github/workflows/e2e-bot.yml index 944f7de87..755e44b8f 100644 --- a/.github/workflows/e2e-bot.yml +++ b/.github/workflows/e2e-bot.yml @@ -25,6 +25,11 @@ jobs: contains(github.event.comment.body, '/e2e') && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) runs-on: ubuntu-latest + # Running PR-head code with the provider secret is gated twice: the author_ + # association check above, and this deployment environment. Configure the + # `e2e-bot` environment with required reviewers in repo settings to force a + # human approval per run (actions/untrusted-checkout-toctou). + environment: e2e-bot steps: - name: Acknowledge uses: actions/github-script@v9 @@ -62,7 +67,13 @@ jobs: - name: Check out the PR head env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh pr checkout ${{ github.event.issue.number }} + # Pin to the head commit resolved now and check it out detached, not the + # mutable PR ref: a force-push mid-run can't swap in different code after + # the trusted-author gate passed. + run: | + SHA=$(gh pr view ${{ github.event.issue.number }} --json headRefOid -q .headRefOid) + git fetch -q origin "$SHA" + git checkout -q --detach "$SHA" - name: Build the agent from the PR head # The whole point of the bot is to drive the PR's code, not main-v2's. Fall diff --git a/internal/cli/theme.go b/internal/cli/theme.go index f90a457da..fff65a41b 100644 --- a/internal/cli/theme.go +++ b/internal/cli/theme.go @@ -252,7 +252,7 @@ func parseOSC11Response(s string) (terminalRGB, bool) { payload = strings.TrimSpace(payload) if strings.HasPrefix(payload, "#") { r, g, b, ok := parseHexColor(payload) - return terminalRGB{int(r), int(g), int(b)}, ok + return terminalRGB{r, g, b}, ok } for _, prefix := range []string{"rgb:", "rgba:"} { if strings.HasPrefix(payload, prefix) { @@ -318,15 +318,15 @@ func bgSGR(c cliColor) string { return fmt.Sprintf("\033[48;5;%dm", c.xterm) } -func parseHexColor(hex string) (int64, int64, int64, bool) { +func parseHexColor(hex string) (int, int, int, bool) { hex = strings.TrimPrefix(hex, "#") if len(hex) != 6 { return 0, 0, 0, false } - r, errR := strconv.ParseInt(hex[0:2], 16, 64) - g, errG := strconv.ParseInt(hex[2:4], 16, 64) - b, errB := strconv.ParseInt(hex[4:6], 16, 64) - return r, g, b, errR == nil && errG == nil && errB == nil + r, errR := strconv.ParseUint(hex[0:2], 16, 8) + g, errG := strconv.ParseUint(hex[2:4], 16, 8) + b, errB := strconv.ParseUint(hex[4:6], 16, 8) + return int(r), int(g), int(b), errR == nil && errG == nil && errB == nil } func supportsTrueColor() bool { diff --git a/internal/codegraph/install.go b/internal/codegraph/install.go index bacbc9ddf..dea87060f 100644 --- a/internal/codegraph/install.go +++ b/internal/codegraph/install.go @@ -258,27 +258,42 @@ func sha256For(sums, name string) (string, error) { return "", fmt.Errorf("codegraph: %s not listed in SHA256SUMS", name) } -// safeJoin joins dir and a (possibly hostile) archive entry name, rejecting any -// path that would escape dir — the zip-slip / tar-slip guard. -func safeJoin(dir, name string) (string, error) { - p := filepath.Join(dir, name) - if p != dir && !strings.HasPrefix(p, dir+string(os.PathSeparator)) { +// resolveWithin returns the real path to write archive entry name under root +// (parents created), rejecting escapes. EvalSymlinks on the parent also catches +// the symlink-redirect variant a lexical "../" check misses: a parent component +// an earlier entry turned into a symlink, written *through* to land outside root. +func resolveWithin(root, name string) (string, error) { + target := filepath.Join(root, name) + if target != root && !strings.HasPrefix(target, root+string(os.PathSeparator)) { return "", fmt.Errorf("unsafe path %q in archive", name) } - return p, nil + if target == root { + return root, nil + } + parent := filepath.Dir(target) + if err := os.MkdirAll(parent, 0o755); err != nil { + return "", err + } + realParent, err := filepath.EvalSymlinks(parent) + if err != nil { + return "", err + } + if realParent != root && !strings.HasPrefix(realParent, root+string(os.PathSeparator)) { + return "", fmt.Errorf("unsafe path %q in archive: escapes via symlink", name) + } + return filepath.Join(realParent, filepath.Base(target)), nil } -// safeSymlink rejects a symlink whose destination escapes dir. linkPath is the -// symlink's own (already safeJoin'd) location; linkname is its raw target. Without -// this a symlink to ../../etc lets a later archive entry written "through" it land -// outside dir — the tar-slip-via-symlink the path check alone misses. -func safeSymlink(dir, linkPath, linkname string) error { +// symlinkWithin rejects a symlink whose target escapes root. linkPath is the +// symlink's already-resolved real location (from resolveWithin), so a relative +// target is judged from where the link truly lands, not its lexical archive path. +func symlinkWithin(root, linkPath, linkname string) error { dest := linkname if !filepath.IsAbs(dest) { dest = filepath.Join(filepath.Dir(linkPath), linkname) } dest = filepath.Clean(dest) - if dest != dir && !strings.HasPrefix(dest, dir+string(os.PathSeparator)) { + if dest != root && !strings.HasPrefix(dest, root+string(os.PathSeparator)) { return fmt.Errorf("unsafe symlink %q -> %q in archive", linkPath, linkname) } return nil @@ -290,6 +305,10 @@ func extractTarGz(data []byte, dir string) error { return err } defer gz.Close() + root, err := filepath.EvalSymlinks(dir) + if err != nil { + return err + } tr := tar.NewReader(gz) for { hdr, err := tr.Next() @@ -299,7 +318,7 @@ func extractTarGz(data []byte, dir string) error { if err != nil { return err } - target, err := safeJoin(dir, hdr.Name) + target, err := resolveWithin(root, hdr.Name) if err != nil { return err } @@ -309,10 +328,7 @@ func extractTarGz(data []byte, dir string) error { return err } case tar.TypeSymlink: - if err := safeSymlink(dir, target, hdr.Linkname); err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + if err := symlinkWithin(root, target, hdr.Linkname); err != nil { return err } _ = os.Remove(target) @@ -332,8 +348,12 @@ func extractZip(data []byte, dir string) error { if err != nil { return err } + root, err := filepath.EvalSymlinks(dir) + if err != nil { + return err + } for _, f := range zr.File { - target, err := safeJoin(dir, f.Name) + target, err := resolveWithin(root, f.Name) if err != nil { return err } @@ -357,9 +377,6 @@ func extractZip(data []byte, dir string) error { } func writeFileFromReader(target string, r io.Reader, mode os.FileMode) error { - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return err - } f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode.Perm()) if err != nil { return err diff --git a/internal/codegraph/install_test.go b/internal/codegraph/install_test.go index 08489492d..b846823f0 100644 --- a/internal/codegraph/install_test.go +++ b/internal/codegraph/install_test.go @@ -76,13 +76,16 @@ func TestSha256For(t *testing.T) { } } -func TestSafeJoinRejectsTraversal(t *testing.T) { - dir := t.TempDir() - if _, err := safeJoin(dir, "../escape"); err == nil { - t.Fatal("safeJoin should reject ../escape") +func TestResolveWithinRejectsTraversal(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if _, err := resolveWithin(root, "../escape"); err == nil { + t.Fatal("resolveWithin should reject ../escape") } - if _, err := safeJoin(dir, "bin/codegraph"); err != nil { - t.Fatalf("safeJoin rejected a normal path: %v", err) + if _, err := resolveWithin(root, "bin/codegraph"); err != nil { + t.Fatalf("resolveWithin rejected a normal path: %v", err) } } diff --git a/internal/codegraph/symlink_escape_test.go b/internal/codegraph/symlink_escape_test.go index c84f2df5b..4ee046193 100644 --- a/internal/codegraph/symlink_escape_test.go +++ b/internal/codegraph/symlink_escape_test.go @@ -35,6 +35,46 @@ func TestExtractRejectsEscapingSymlink(t *testing.T) { } } +// tarGz builds a tar.gz from the given headers in order, each with no body. +func tarGz(hdrs ...*tar.Header) []byte { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for _, h := range hdrs { + _ = tw.WriteHeader(h) + } + tw.Close() + gz.Close() + return buf.Bytes() +} + +// TestExtractRejectsSymlinkRedirectEscape covers the variant a lexical path check +// misses: an in-bounds symlink ("real/up" -> "..", which lands on root) is made a +// parent of a second symlink, so the second link's real location is root/escape +// even though its archive path is real/up/escape. Resolving the parent before +// validating the target is what catches it (go/unsafe-unzip-symlink). +func TestExtractRejectsSymlinkRedirectEscape(t *testing.T) { + dir := t.TempDir() + if err := os.Symlink("probe-target", filepath.Join(dir, "probe-link")); err != nil { + t.Skipf("symlink creation is not available in this environment: %v", err) + } + _ = os.Remove(filepath.Join(dir, "probe-link")) + data := tarGz( + &tar.Header{Name: "real/", Typeflag: tar.TypeDir, Mode: 0o755}, + &tar.Header{Name: "real/up", Typeflag: tar.TypeSymlink, Linkname: "..", Mode: 0o777}, + &tar.Header{Name: "real/up/escape", Typeflag: tar.TypeSymlink, Linkname: "..", Mode: 0o777}, + ) + err := extractTarGz(data, dir) + if err == nil || !strings.Contains(err.Error(), "unsafe symlink") { + t.Fatalf("symlink-redirect escape should be rejected, got %v", err) + } + if _, err := os.Lstat(filepath.Dir(dir)); err == nil { + if _, err := os.Lstat(filepath.Join(filepath.Dir(dir), "escape")); err == nil { + t.Fatal("escape symlink was planted outside the extraction dir") + } + } +} + // TestExtractAllowsInternalSymlink keeps legitimate in-bundle symlinks working. func TestExtractAllowsInternalSymlink(t *testing.T) { dir := t.TempDir() diff --git a/internal/diff/diff.go b/internal/diff/diff.go index 1850476ad..60df68078 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -154,8 +154,8 @@ func myers(a, b []string) ([]op, bool) { return nil, true } maxD := n + m - if maxD > maxDiffEdits { - maxD = maxDiffEdits // bound the trace's O(D²) footprint + if maxD > maxDiffEdits || maxD < 0 { + maxD = maxDiffEdits // bound the trace's O(D²) footprint (and any n+m overflow) } offset := maxD // shift negative k into a non-negative array index v := make([]int, 2*maxD+1) diff --git a/scripts/backfill-issue-labels.mjs b/scripts/backfill-issue-labels.mjs index 985190c56..39d14eabb 100644 --- a/scripts/backfill-issue-labels.mjs +++ b/scripts/backfill-issue-labels.mjs @@ -11,7 +11,7 @@ // --only-unlabeled skip issues that already have an area label // --limit N process at most N issues (default 200) -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; const args = process.argv.slice(2); const dryRun = args.includes('--dry-run'); @@ -52,8 +52,8 @@ const SYSTEM = [ 'Reply with JSON only: {"area":[],"platform":[],"severity":[]}', ].join('\n'); -function gh(cmd) { - return execSync(`gh ${cmd}`, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); +function gh(args) { + return execFileSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); } async function classify(title, body) { @@ -76,7 +76,9 @@ async function classify(title, body) { return [...(parsed.area || []), ...(parsed.platform || []), ...(parsed.severity || [])].filter((l) => ALLOWED.has(l)); } -const issues = JSON.parse(gh(`issue list --state open --limit ${limit} --json number,title,body,labels`)); +const issues = JSON.parse( + gh(['issue', 'list', '--state', 'open', '--limit', String(limit), '--json', 'number,title,body,labels']), +); console.log(`${issues.length} open issues; dryRun=${dryRun} onlyUnlabeled=${onlyUnlabeled}`); let changed = 0; @@ -101,8 +103,7 @@ for (const it of issues) { if (dryRun) { console.log(`#${it.number}: would add ${toAdd.join(', ')} — ${it.title.slice(0, 50)}`); } else { - const flags = toAdd.map((l) => `--add-label "${l}"`).join(' '); - gh(`issue edit ${it.number} ${flags}`); + gh(['issue', 'edit', String(it.number), ...toAdd.flatMap((l) => ['--add-label', l])]); console.log(`#${it.number}: +${toAdd.join(', ')}`); } changed++; From 07de1b57e3c3947eb2acd97ce2667c64636d8cc1 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 9 Jun 2026 17:22:00 -0700 Subject: [PATCH 37/64] fix(desktop): strip controller-injected prefixes from chat display (#3720) Strip controller-injected prefixes ([Plan mode] marker, , ) and synthetic Role:user messages from the chat display so agent-mode internals no longer leak into the desktop/TUI transcript. Closes #3738, Closes #3720. --- desktop/app.go | 3 + desktop/sessions.go | 3 +- internal/cli/chat_tui.go | 2 +- internal/control/input.go | 58 +++++++++++++++ internal/control/input_test.go | 124 +++++++++++++++++++++++++++++++++ 5 files changed, 188 insertions(+), 2 deletions(-) diff --git a/desktop/app.go b/desktop/app.go index 702069446..203de28de 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -1293,6 +1293,9 @@ func historyMessages(msgs []provider.Message, resolveUserContent func(string) st content := m.Content if m.Role == provider.RoleUser { content = resolveUserContent(m.Content) + if control.IsSyntheticUserMessage(content) { + continue + } } reasoning := "" if m.Role == provider.RoleAssistant { diff --git a/desktop/sessions.go b/desktop/sessions.go index 1b8b5d4ae..b9a57aa0f 100644 --- a/desktop/sessions.go +++ b/desktop/sessions.go @@ -11,6 +11,7 @@ import ( "time" "reasonix/internal/agent" + "reasonix/internal/control" "reasonix/internal/fileutil" ) @@ -471,7 +472,7 @@ func sessionDisplayResolver(dir, sessionPath string) func(content string) string return display } } - return content + return control.StripComposePrefixes(content) } } diff --git a/internal/cli/chat_tui.go b/internal/cli/chat_tui.go index 56fc6a804..2b058f450 100644 --- a/internal/cli/chat_tui.go +++ b/internal/cli/chat_tui.go @@ -3448,7 +3448,7 @@ func replaySectionsFor(history []provider.Message, width int, renderer *mdRender for _, m := range history { switch m.Role { case provider.RoleUser: - content := strings.TrimPrefix(m.Content, control.PlanModeMarker+"\n\n") + content := control.StripComposePrefixes(m.Content) out = append(out, renderUserBubble(content, width, false)+"\n\n") case provider.RoleAssistant: body := strings.TrimSpace(m.Content) diff --git a/internal/control/input.go b/internal/control/input.go index e56cf0d12..27c5bd281 100644 --- a/internal/control/input.go +++ b/internal/control/input.go @@ -2,16 +2,74 @@ package control import ( "context" + "regexp" "strings" "reasonix/internal/skill" ) +var reComposeBlock = regexp.MustCompile(`(?s)^\s*<(?:memory-update|background-jobs)>.*?\s*\n`) + // PlanModeMarker is prepended to every user turn while plan mode is on. It rides // in the user message (not the system prompt or tools), so the cache-stable // prompt prefix is left untouched and the toggle costs nothing in cache hits. const PlanModeMarker = "[Plan mode — read-only. Explore the codebase first (read_file, ls, grep, glob, web_fetch, task are available; writers are refused by the harness), then present a LAYERED plan as your reply and stop — do not write files, edit, or run side-effecting bash. Structure the plan as a two-level markdown list so it becomes a layered task list: each PHASE is a top-level numbered list item (a coherent milestone, e.g. \"1. Add the config loader\"), and each phase's concrete, verifiable sub-steps are bullets indented beneath it (e.g. \" - parse the TOML into Config\"). Use plain numbered list items for phases — do NOT write phases as markdown headings (##, ###) — so both levels parse. Keep phases few (about 2-6). The user will be asked to approve before any changes are made.]" +// StripComposePrefixes removes controller-injected prefixes from a composed +// user message so that the display text matches what the user actually typed. +// It strips the PlanModeMarker, , and +// blocks that Compose prepends to user +// turns. This is used as a fallback when no .display.json sidecar recording +// exists (e.g. sessions created before the display-recording feature, or +// synthetic user messages injected by the controller). +func StripComposePrefixes(content string) string { + s := content + for { + next := reComposeBlock.ReplaceAllStringFunc(s, func(match string) string { + return "" + }) + if next == s { + break + } + s = next + } + s = strings.TrimPrefix(s, PlanModeMarker+"\n\n") + s = strings.TrimPrefix(s, PlanModeMarker) + s = strings.TrimSpace(s) + return s +} + +// IsSyntheticUserMessage returns true if the content matches one of the known +// synthetic user messages injected by the controller or agent loop (plan +// approval, stream recovery, readiness retry, etc.). These should not be shown +// in the chat UI. +func IsSyntheticUserMessage(content string) bool { + trimmed := strings.TrimSpace(content) + if trimmed == planApprovedMessage { + return true + } + for _, prefix := range syntheticPrefixes { + if strings.HasPrefix(trimmed, prefix) { + return true + } + } + return false +} + +// syntheticPrefixes must be kept in sync with the synthetic user messages +// injected by the controller (planApprovedMessage) and agent loop +// (streamRecoveryMessage, finalReadinessRetryMessage, emptyFinalRetryMessage, +// executorHandoffRetryMessage in internal/agent/agent.go). +var syntheticPrefixes = []string{ + "Plan approved — plan mode is off", + "Host final-answer readiness check failed", + "You are already in the executor phase", + "The previous assistant response was interrupted while a tool call", + "The previous assistant response was interrupted during streaming", + "The previous assistant response was interrupted before visible", + "The previous assistant response finished without any visible answer", +} + // Compose applies the plan-mode marker to a turn's text when plan mode is on, // returning the message to actually send to the model. The frontend keeps // showing the raw text as the user bubble. diff --git a/internal/control/input_test.go b/internal/control/input_test.go index 2b6336b2e..a5cb4cf60 100644 --- a/internal/control/input_test.go +++ b/internal/control/input_test.go @@ -371,3 +371,127 @@ func TestRunTurnAutoPlanScoresRawPromptNotResolvedRefs(t *testing.T) { t.Fatal("controller plan mode should remain off") } } + +func TestStripComposePrefixes(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "plain user message unchanged", + input: "explain this function", + want: "explain this function", + }, + { + name: "plan mode marker stripped", + input: PlanModeMarker + "\n\nexplain this function", + want: "explain this function", + }, + { + name: "plan mode marker without trailing newlines", + input: PlanModeMarker, + want: "", + }, + { + name: "memory update block stripped", + input: "\nThe following project-memory changes were just made and apply from now on:\n- Saved memory \"rmb\": user balance\n\n\nexplain this", + want: "explain this", + }, + { + name: "background jobs block stripped", + input: "\n1 completed\n\n\nexplain this", + want: "explain this", + }, + { + name: "memory and plan marker both stripped", + input: "\n- note\n\n\n" + PlanModeMarker + "\n\nexplain this", + want: "explain this", + }, + { + name: "empty after stripping", + input: PlanModeMarker + "\n\n", + want: "", + }, + { + name: "memory update only no user text", + input: "\n- note\n\n\n", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := StripComposePrefixes(tt.input) + if got != tt.want { + t.Errorf("StripComposePrefixes() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestIsSyntheticUserMessage(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + { + name: "plan approved message", + input: planApprovedMessage, + want: true, + }, + { + name: "stream recovery interrupted tool", + input: "The previous assistant response was interrupted while a tool call was streaming. Continue the same task now.", + want: true, + }, + { + name: "stream recovery interrupted text", + input: "The previous assistant response was interrupted during streaming. Continue the same task from immediately after the partial assistant message above.", + want: true, + }, + { + name: "empty final retry", + input: "The previous assistant response finished without any visible answer text. Continue the same task now and provide a concise visible answer.", + want: true, + }, + { + name: "readiness retry", + input: "Host final-answer readiness check failed. Before giving a final answer, address the missing host-observable receipts: missing evidence.", + want: true, + }, + { + name: "executor handoff", + input: "You are already in the executor phase. The planner's read-only limitations do not apply to you.", + want: true, + }, + { + name: "regular user message", + input: "explain this function", + want: false, + }, + { + name: "plan mode marker in message", + input: PlanModeMarker + "\n\nexplain this", + want: false, + }, + { + name: "stream recovery interrupted before visible", + input: "The previous assistant response was interrupted during streaming before visible answer text was completed. Continue the same task now.", + want: true, + }, + { + name: "user quoting interrupted response not synthetic", + input: "The previous assistant response was interrupted by my VPN, can you retry?", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsSyntheticUserMessage(tt.input) + if got != tt.want { + t.Errorf("IsSyntheticUserMessage() = %v, want %v", got, tt.want) + } + }) + } +} From a64c91798f68559df21a5cdca16f93a25cf85fa1 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 9 Jun 2026 17:59:38 -0700 Subject: [PATCH 38/64] fix(provider): time out a stalled SSE stream instead of hanging (#3374) (#3745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * @ fix(provider): time out a stalled SSE stream instead of hanging A half-open connection (a proxy switched mid-stream, no RST) left scanner.Scan() blocked forever: the UI sat on "thinking", Ctrl+C/Esc were unresponsive, and the only way out was kill -9. Add an idle watchdog to both the openai-compatible and anthropic stream readers — if a started stream goes silent past streamIdleTimeout the body is closed and a clear "stream stalled" error surfaces, returning the session to the input prompt. The watchdog owns the timer; the read loop pings a buffered channel, so there is no Timer.Reset race, and the openai replay path (#3148) is untouched. Closes #3374. @ * fix(provider): make the SSE idle timeout per-client, not a global var The first cut used a package-level var so tests could shorten it, but a test writing it raced another test stream watchdog reading it under go test -race. Store the window on the client (defaultStreamIdleTimeout via New); tests set the field on their own client before Stream, so the go statement orders the write before the watchdog read. A zero-value client falls back to the default. --------- Co-authored-by: reasonix --- internal/provider/anthropic/anthropic.go | 86 ++++++++++++++++---- internal/provider/anthropic/stall_test.go | 65 +++++++++++++++ internal/provider/openai/openai.go | 97 +++++++++++++++++------ internal/provider/openai/stall_test.go | 65 +++++++++++++++ 4 files changed, 271 insertions(+), 42 deletions(-) create mode 100644 internal/provider/anthropic/stall_test.go create mode 100644 internal/provider/openai/stall_test.go diff --git a/internal/provider/anthropic/anthropic.go b/internal/provider/anthropic/anthropic.go index b7fe900e3..fe3b59606 100644 --- a/internal/provider/anthropic/anthropic.go +++ b/internal/provider/anthropic/anthropic.go @@ -25,11 +25,20 @@ import ( "net/http" "strings" "sync" + "sync/atomic" + "time" "reasonix/internal/netclient" "reasonix/internal/provider" ) +// defaultStreamIdleTimeout caps how long a started SSE stream may go silent before +// it's treated as a dropped connection — a half-open TCP connection (proxy switched +// mid-stream) sends no RST, so scanner.Scan() would block forever. Generous on +// purpose; live streams emit far more often. Stored per-client (client.idleTimeout) +// so a test can shorten it without a shared global that races other watchdogs. +const defaultStreamIdleTimeout = 120 * time.Second + const ( // anthropicVersion is the required API version header value. anthropicVersion = "2023-06-01" @@ -83,14 +92,15 @@ func New(cfg provider.Config) (provider.Provider, error) { root = defaultBaseURL } return &client{ - name: name, - apiKey: cfg.APIKey, - keyEnv: keyEnv, - baseURL: root, - model: cfg.Model, - thinking: thinking, - effort: effort, - http: httpClient, // no overall timeout; lifecycle is ctx-driven + name: name, + apiKey: cfg.APIKey, + keyEnv: keyEnv, + baseURL: root, + model: cfg.Model, + thinking: thinking, + effort: effort, + http: httpClient, // no overall timeout; lifecycle is ctx-driven + idleTimeout: defaultStreamIdleTimeout, }, nil } @@ -100,14 +110,15 @@ func newHTTPClient(cfg provider.Config) (*http.Client, error) { } type client struct { - name string - apiKey string - keyEnv string // api_key_env name, surfaced in auth errors - baseURL string - model string - thinking string // "adaptive" enables extended thinking; "" = off (config-driven) - effort string // output_config.effort: low|medium|high|xhigh|max; "" = provider default - http *http.Client + name string + apiKey string + keyEnv string // api_key_env name, surfaced in auth errors + baseURL string + model string + thinking string // "adaptive" enables extended thinking; "" = off (config-driven) + effort string // output_config.effort: low|medium|high|xhigh|max; "" = provider default + http *http.Client + idleTimeout time.Duration // SSE stall watchdog window; defaultStreamIdleTimeout unless a test overrides } func (c *client) Name() string { return c.name } @@ -269,6 +280,41 @@ func (c *client) readStream(resp *http.Response, out chan<- provider.Chunk) { defer resp.Body.Close() defer close(out) + // Close the body if the stream stalls past c.idleTimeout so scanner.Scan() + // unblocks instead of hanging on a half-open connection. The watchdog owns the + // timer; the read loop only pings the buffered activity channel (no Timer.Reset + // race). A context cancel already unblocks the scan via the transport. + idleTimeout := c.idleTimeout + if idleTimeout <= 0 { // zero-value client (constructed without New) + idleTimeout = defaultStreamIdleTimeout + } + done := make(chan struct{}) + defer close(done) + activity := make(chan struct{}, 1) + var stalled atomic.Bool + go func() { + idle := time.NewTimer(idleTimeout) + defer idle.Stop() + for { + select { + case <-idle.C: + stalled.Store(true) + resp.Body.Close() + return + case <-activity: + if !idle.Stop() { + select { + case <-idle.C: + default: + } + } + idle.Reset(idleTimeout) + case <-done: + return + } + } + }() + tools := map[int]*provider.ToolCall{} // tool_use blocks, keyed by content index var inTok, outTok, cacheCreate, cacheRead int var stopReason string @@ -278,6 +324,10 @@ func (c *client) readStream(resp *http.Response, out chan<- provider.Chunk) { scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { + select { // ping the idle watchdog; non-blocking so a full buffer is fine + case activity <- struct{}{}: + default: + } line := strings.TrimSpace(scanner.Text()) // SSE carries `event:` and `data:` lines; the data JSON's own `type` field // is authoritative, so we only need the data payloads. @@ -356,6 +406,10 @@ func (c *client) readStream(resp *http.Response, out chan<- provider.Chunk) { } } + if stalled.Load() { + out <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf("%s: stream stalled — no data for %s, connection likely dropped", c.name, idleTimeout)} + return + } if err := scanner.Err(); err != nil { out <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf("%s: read stream: %w", c.name, err)} return diff --git a/internal/provider/anthropic/stall_test.go b/internal/provider/anthropic/stall_test.go new file mode 100644 index 000000000..93d4b718d --- /dev/null +++ b/internal/provider/anthropic/stall_test.go @@ -0,0 +1,65 @@ +package anthropic + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "reasonix/internal/provider" +) + +// TestStreamStallTimesOut covers issue #3374 for the Anthropic provider: a +// half-open connection sends the SSE head then goes silent without an RST, which +// would hang scanner.Scan() forever. The idle watchdog must surface a stall error. +func TestStreamStallTimesOut(t *testing.T) { + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + _, _ = io.WriteString(w, ": ping\n\n") + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + <-release // stall: never send data, never close + })) + defer srv.Close() + defer close(release) + + p, err := New(provider.Config{Name: "claude", BaseURL: srv.URL, Model: "claude-opus-4-8", APIKey: "k"}) + if err != nil { + t.Fatalf("New: %v", err) + } + p.(*client).idleTimeout = 150 * time.Millisecond + ch, err := p.Stream(context.Background(), provider.Request{ + Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}, + MaxTokens: 16, + }) + if err != nil { + t.Fatalf("Stream: %v", err) + } + + deadline := time.After(5 * time.Second) + for { + select { + case chunk, ok := <-ch: + if !ok { + t.Fatal("stream closed without surfacing a stall error") + } + if chunk.Type == provider.ChunkError { + if !strings.Contains(chunk.Err.Error(), "stalled") { + t.Fatalf("error = %v, want a 'stalled' error", chunk.Err) + } + return + } + case <-deadline: + t.Fatal("stream did not time out on a stalled connection — it hung") + } + } +} diff --git a/internal/provider/openai/openai.go b/internal/provider/openai/openai.go index 8b8159161..3c64b9eb4 100644 --- a/internal/provider/openai/openai.go +++ b/internal/provider/openai/openai.go @@ -20,12 +20,22 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" "reasonix/internal/netclient" "reasonix/internal/provider" ) +// defaultStreamIdleTimeout caps how long a started SSE stream may go without any +// bytes before it's treated as a dropped connection. A half-open TCP connection +// (e.g. a proxy switched mid-stream) sends no RST, so scanner.Scan() would block +// forever; this turns that hang into a recoverable error. Generous on purpose — +// live streams emit tokens/keepalives far more often. Stored per-client +// (client.idleTimeout) so a test can shorten it without a shared global that +// would race other streams' watchdogs. +const defaultStreamIdleTimeout = 120 * time.Second + func init() { provider.Register("openai", New) } @@ -93,15 +103,16 @@ func New(cfg provider.Config) (provider.Provider, error) { return nil, fmt.Errorf("openai: network: %w", err) } return &client{ - name: name, - apiKey: cfg.APIKey, - keyEnv: keyEnv, - baseURL: strings.TrimRight(cfg.BaseURL, "/"), - model: cfg.Model, - deepseek: deepseek, - minimax: minimax, - effort: effort, - http: httpClient, + name: name, + apiKey: cfg.APIKey, + keyEnv: keyEnv, + baseURL: strings.TrimRight(cfg.BaseURL, "/"), + model: cfg.Model, + deepseek: deepseek, + minimax: minimax, + effort: effort, + http: httpClient, + idleTimeout: defaultStreamIdleTimeout, }, nil } @@ -116,15 +127,16 @@ func newHTTPClient(cfg provider.Config) (*http.Client, error) { } type client struct { - name string - apiKey string - keyEnv string // api_key_env name, surfaced in auth errors - baseURL string - model string - http *http.Client - deepseek bool - minimax bool // true for api.minimaxi.com — emits MiniMax-M3's thinking knob instead of reasoning_effort - effort string // reasoning_effort for OpenAI; thinking.type for MiniMax; "" = auto/provider default + name string + apiKey string + keyEnv string // api_key_env name, surfaced in auth errors + baseURL string + model string + http *http.Client + deepseek bool + minimax bool // true for api.minimaxi.com — emits MiniMax-M3's thinking knob instead of reasoning_effort + effort string // reasoning_effort for OpenAI; thinking.type for MiniMax; "" = auto/provider default + idleTimeout time.Duration // SSE stall watchdog window; defaultStreamIdleTimeout unless a test overrides } func (c *client) Name() string { return c.name } @@ -290,17 +302,43 @@ func (c *client) buildRequest(req provider.Request) chatRequest { func (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) (emitted bool, _ error) { defer resp.Body.Close() - // Close the response body when the context is canceled so scanner.Scan() - // unblocks instead of hanging on a stalled connection. done lets the goroutine - // exit when readStream returns normally — otherwise it outlives the call, and - // blocks forever on a non-cancellable context whose Done() is nil. + // Close the response body when the context is canceled (user interrupt) or the + // stream stalls past c.idleTimeout, so scanner.Scan() unblocks instead of + // hanging on a half-open connection. done lets the watchdog exit on a normal + // return — otherwise it outlives the call and blocks forever on a non-cancellable + // context whose Done() is nil. The watchdog owns the timer; the read loop only + // pings the buffered activity channel, so there's no Timer.Reset race. + idleTimeout := c.idleTimeout + if idleTimeout <= 0 { // zero-value client (constructed without New) + idleTimeout = defaultStreamIdleTimeout + } done := make(chan struct{}) defer close(done) + activity := make(chan struct{}, 1) + var stalled atomic.Bool go func() { - select { - case <-ctx.Done(): - resp.Body.Close() - case <-done: + idle := time.NewTimer(idleTimeout) + defer idle.Stop() + for { + select { + case <-ctx.Done(): + resp.Body.Close() + return + case <-idle.C: + stalled.Store(true) + resp.Body.Close() + return + case <-activity: + if !idle.Stop() { + select { + case <-idle.C: + default: + } + } + idle.Reset(idleTimeout) + case <-done: + return + } } }() @@ -314,6 +352,10 @@ func (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { + select { // ping the idle watchdog; non-blocking so a full buffer is fine + case activity <- struct{}{}: + default: + } line := strings.TrimSpace(scanner.Text()) if line == "" || !strings.HasPrefix(line, "data:") { continue @@ -384,6 +426,9 @@ func (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- } } + if stalled.Load() { + return emitted, fmt.Errorf("%s: stream stalled — no data for %s, connection likely dropped", c.name, idleTimeout) + } if err := scanner.Err(); err != nil { return emitted, fmt.Errorf("%s: read stream: %w", c.name, err) } diff --git a/internal/provider/openai/stall_test.go b/internal/provider/openai/stall_test.go new file mode 100644 index 000000000..9b27c0b19 --- /dev/null +++ b/internal/provider/openai/stall_test.go @@ -0,0 +1,65 @@ +package openai + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "reasonix/internal/provider" +) + +// TestStreamStallTimesOut covers issue #3374: a half-open connection (a proxy +// switched mid-stream) sends the SSE head then goes silent without an RST, so +// scanner.Scan() would block forever and Ctrl+C-less sessions hang until kill -9. +// The idle watchdog must surface a stall error instead of hanging. +func TestStreamStallTimesOut(t *testing.T) { + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flush(w) + _, _ = io.WriteString(w, ": keep-alive\n\n") // one comment, resets the watchdog once + flush(w) + <-release // then stall: never send data, never close — half-open connection + })) + defer srv.Close() + defer close(release) + + p, err := New(provider.Config{Name: "deepseek", BaseURL: srv.URL, Model: "deepseek-v4", APIKey: "k"}) + if err != nil { + t.Fatalf("New: %v", err) + } + p.(*client).idleTimeout = 150 * time.Millisecond + ch, err := p.Stream(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}}}) + if err != nil { + t.Fatalf("Stream: %v", err) + } + + deadline := time.After(5 * time.Second) + for { + select { + case chunk, ok := <-ch: + if !ok { + t.Fatal("stream closed without surfacing a stall error") + } + if chunk.Type == provider.ChunkError { + if !strings.Contains(chunk.Err.Error(), "stalled") { + t.Fatalf("error = %v, want a 'stalled' error", chunk.Err) + } + return + } + case <-deadline: + t.Fatal("stream did not time out on a stalled connection — it hung") + } + } +} + +func flush(w http.ResponseWriter) { + if f, ok := w.(http.Flusher); ok { + f.Flush() + } +} From 137e3c6865ec11505e9dd0a1c872185952587f54 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 9 Jun 2026 18:24:44 -0700 Subject: [PATCH 39/64] fix(bash): reap the process group after a command completes (#3702) (#3748) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(bash): reap the process group after a command completes A foreground bash command that forked a lingering child (e.g. bazel run, which starts a persistent server) left it alive: Setpgid put it in the command process group, but cmd.Wait only reaps the shell leader and setKillTree only kills the group on cancel/timeout. Normal completion left the strays running, accumulating into an OOM (#3702). Kill the group after Run() on both the foreground and background paths. POSIX-only; Windows is a documented no-op (after-exit tree cleanup there needs a Job Object). * test(bash): fix reap test — use CommandContext and pass pid via file The first cut used exec.Command with setKillTree, but Go rejects a non-nil Cancel without CommandContext; it also read the bg pid from the inherited stdout, which the backgrounded child holds open. Create with context.Background(), redirect the child fds, and pass the pid through a temp file. --------- Co-authored-by: reasonix --- internal/tool/builtin/bash.go | 9 +++- internal/tool/builtin/bash_kill_other.go | 11 +++++ internal/tool/builtin/bash_kill_windows.go | 7 +++ internal/tool/builtin/bash_reap_test.go | 57 ++++++++++++++++++++++ 4 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 internal/tool/builtin/bash_reap_test.go diff --git a/internal/tool/builtin/bash.go b/internal/tool/builtin/bash.go index 10cbc86f9..2702b98a9 100644 --- a/internal/tool/builtin/bash.go +++ b/internal/tool/builtin/bash.go @@ -149,7 +149,9 @@ func (b bash) Execute(ctx context.Context, args json.RawMessage) (string, error) cmd.WaitDelay = bashWaitDelay cmd.Stdout = out cmd.Stderr = out - return "", cmd.Run() + runErr := cmd.Run() + reapTree(cmd) // reap process-group stragglers the job left running (#3702) + return "", runErr }) return fmt.Sprintf("Started background job %q. It keeps running across turns; read new output with bash_output(job_id=%q), wait for it with wait, or stop it with kill_shell(job_id=%q).", job.ID, job.ID, job.ID), nil } @@ -175,6 +177,11 @@ func (b bash) Execute(ctx context.Context, args json.RawMessage) (string, error) cmd.Stdout = w cmd.Stderr = w err := cmd.Run() + // A foreground command that spawned a lingering child (e.g. `bazel run`'s + // server) leaves it in the process group; Wait only reaped the shell leader. + // Kill the group so those don't accumulate into an OOM (#3702). On cancel/ + // timeout setKillTree's Cancel already did this; this covers normal exit. + reapTree(cmd) out := buf.String() if errors.Is(context.Cause(runCtx), errBashTimeout) { diff --git a/internal/tool/builtin/bash_kill_other.go b/internal/tool/builtin/bash_kill_other.go index 8741075a7..5076ce994 100644 --- a/internal/tool/builtin/bash_kill_other.go +++ b/internal/tool/builtin/bash_kill_other.go @@ -20,3 +20,14 @@ func setKillTree(cmd *exec.Cmd) { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } } + +// reapTree kills any members the command's process group left running after it +// returned — a foreground command that forked a daemon (e.g. `bazel run`'s +// server) leaves it behind, and Wait only reaped the shell leader. The group id +// is the leader's pid (Setpgid). ESRCH (empty group) is fine. See #3702. +func reapTree(cmd *exec.Cmd) { + if cmd.Process == nil { + return + } + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) +} diff --git a/internal/tool/builtin/bash_kill_windows.go b/internal/tool/builtin/bash_kill_windows.go index f728f7b73..f9366b721 100644 --- a/internal/tool/builtin/bash_kill_windows.go +++ b/internal/tool/builtin/bash_kill_windows.go @@ -23,3 +23,10 @@ func setKillTree(cmd *exec.Cmd) { return cmd.Process.Kill() } } + +// reapTree is the post-completion process-group cleanup the POSIX build does for +// #3702. On Windows it's a no-op: once the shell leader exits there's no live +// parent for taskkill /T to walk, and spawning taskkill on every bash call would +// tax the hot path for little gain — proper after-exit tree cleanup needs a Job +// Object, which is out of scope here. +func reapTree(*exec.Cmd) {} diff --git a/internal/tool/builtin/bash_reap_test.go b/internal/tool/builtin/bash_reap_test.go new file mode 100644 index 000000000..f06fddafc --- /dev/null +++ b/internal/tool/builtin/bash_reap_test.go @@ -0,0 +1,57 @@ +//go:build !windows + +package builtin + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +// TestReapTreeKillsGroupStragglers covers #3702: a foreground command that +// backgrounds a child (here a long sleep, standing in for `bazel run`'s server) +// leaves it in the process group after Wait reaps the shell leader. reapTree must +// kill it so such processes don't accumulate into an OOM. The child redirects its +// fds and the pid is passed via a file so the inherited stdout can't block Wait. +func TestReapTreeKillsGroupStragglers(t *testing.T) { + pidFile := filepath.Join(t.TempDir(), "pid") + cmd := exec.CommandContext(context.Background(), "sh", "-c", + "sleep 60 >/dev/null 2>&1 & echo $! > "+pidFile) + setKillTree(cmd) // Setpgid — the shell leads its own group + if err := cmd.Run(); err != nil { + t.Fatalf("run: %v", err) + } + + data, err := os.ReadFile(pidFile) + if err != nil { + t.Fatalf("read pid file: %v", err) + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + t.Fatalf("parse backgrounded pid %q: %v", data, err) + } + if err := syscall.Kill(pid, 0); err != nil { + t.Skipf("backgrounded child %d not alive after shell exit (%v)", pid, err) + } + + reapTree(cmd) + + dead := false + for i := 0; i < 50; i++ { + if syscall.Kill(pid, 0) != nil { + dead = true + break + } + time.Sleep(20 * time.Millisecond) + } + if !dead { + _ = syscall.Kill(pid, syscall.SIGKILL) // don't leak the sleep in CI + t.Fatalf("backgrounded child %d survived reapTree", pid) + } +} From bdabda35d1924ac77d6bd168a97a30836a8fb61e Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 9 Jun 2026 19:44:35 -0700 Subject: [PATCH 40/64] fix(codegraph): stop leaking detached MCP daemons and indexing the whole drive (#3747) (#3755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(proc): assign the job object before the launcher runs so detached MCP children are reaped A Windows stdio MCP launcher (cmd.exe -> node.exe, as the CodeGraph daemon re-parents itself off its shim) raced the job-object assignment: the job was assigned only after Start returned, so a fast shim could exec its grandchild and exit before the assignment, leaving node.exe orphaned in no job. KillTracked and an abrupt reasonix exit then both missed it, and dozens of codegraph daemons leaked past a session (#3747). Create the child suspended, assign it to the job, then resume — so every descendant is captured before the launcher can spawn anything. * fix(codegraph): refuse to index a filesystem root When the workspace root resolved to a drive root (C:\), CodeGraph's cwd-aware serve --mcp walked the entire volume — C:\Windows, Program Files, everything — pinning ~1GB of RAM (#3747). Reject filesystem roots (and an empty root) at both spawn sites before launching serve. --------- Co-authored-by: reasonix --- internal/boot/boot.go | 3 ++ internal/codegraph/codegraph.go | 18 +++++++++ internal/codegraph/codegraph_test.go | 22 +++++++++++ internal/control/controller.go | 3 ++ internal/plugin/transport_stdio.go | 5 ++- internal/proc/kill_other.go | 9 +++-- internal/proc/kill_other_test.go | 13 +++--- internal/proc/kill_windows.go | 59 +++++++++++++++++++++++----- internal/proc/kill_windows_test.go | 37 +++++++++++++---- 9 files changed, 141 insertions(+), 28 deletions(-) diff --git a/internal/boot/boot.go b/internal/boot/boot.go index ba89cfd74..a058dfff1 100644 --- a/internal/boot/boot.go +++ b/internal/boot/boot.go @@ -245,6 +245,9 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) { if cfg.Codegraph.Enabled { bin, ok := codegraph.Resolve(cfg.Codegraph.Path) switch { + case ok && !codegraph.IndexableRoot(root): + sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, + Text: "codegraph: project root is a filesystem root — skipped to avoid indexing the whole volume"}) case ok: spec := plugin.Spec{ Name: "codegraph", diff --git a/internal/codegraph/codegraph.go b/internal/codegraph/codegraph.go index 6d8f59a61..04e5cf93d 100644 --- a/internal/codegraph/codegraph.go +++ b/internal/codegraph/codegraph.go @@ -159,6 +159,24 @@ func Initialized(root string) bool { return err == nil && fi.IsDir() } +// IndexableRoot reports whether root is a real project directory CodeGraph can +// safely be pinned to. A filesystem root (a Windows drive root like C:\, a UNC +// share root, or the unix /) is rejected: serve --mcp walks its working +// directory, so a root cwd makes it index the whole volume — C:\Windows, +// C:\Program Files, everything — pinning gigabytes of RAM (#3747). An empty +// root is rejected too: there is nothing to pin a cwd-aware server to. +func IndexableRoot(root string) bool { + root = strings.TrimSpace(root) + if root == "" { + return false + } + abs, err := filepath.Abs(root) + if err != nil { + return false + } + return filepath.Dir(abs) != abs // a filesystem root is its own parent +} + func expand(p string) string { p = os.ExpandEnv(p) if strings.HasPrefix(p, "~/") { diff --git a/internal/codegraph/codegraph_test.go b/internal/codegraph/codegraph_test.go index 64c97e4dd..bdd530b5b 100644 --- a/internal/codegraph/codegraph_test.go +++ b/internal/codegraph/codegraph_test.go @@ -91,3 +91,25 @@ func TestEnsureInitPropagatesFailure(t *testing.T) { t.Fatal("EnsureInit should return the init failure") } } + +func TestIndexableRootRejectsFilesystemRoots(t *testing.T) { + if got := IndexableRoot(t.TempDir()); !got { + t.Fatal("a real project dir must be indexable") + } + for _, root := range []string{"", " "} { + if IndexableRoot(root) { + t.Fatalf("IndexableRoot(%q) = true; want false", root) + } + } + var roots []string + if runtime.GOOS == "windows" { + roots = []string{`C:\`, `c:\`, `\\server\share`} + } else { + roots = []string{"/"} + } + for _, root := range roots { + if IndexableRoot(root) { + t.Fatalf("IndexableRoot(%q) = true; want false (filesystem root)", root) + } + } +} diff --git a/internal/control/controller.go b/internal/control/controller.go index d611d3051..1d4baaf24 100644 --- a/internal/control/controller.go +++ b/internal/control/controller.go @@ -1728,6 +1728,9 @@ func (c *Controller) connectCodegraphMCPServer(cfg *config.Config) (int, error) if err != nil { return 0, err } + if !codegraph.IndexableRoot(cwd) { + return 0, fmt.Errorf("codegraph: refusing to index %q — a filesystem root would index the whole volume", cwd) + } if err := codegraph.EnsureInit(c.pluginCtx, bin, cwd); err != nil { return 0, fmt.Errorf("codegraph init: %w", err) } diff --git a/internal/plugin/transport_stdio.go b/internal/plugin/transport_stdio.go index e14e762b4..b968f98ca 100644 --- a/internal/plugin/transport_stdio.go +++ b/internal/plugin/transport_stdio.go @@ -74,13 +74,14 @@ func newStdioTransport(ctx context.Context, s Spec) (*stdioTransport, error) { if err != nil { return nil, err } - if err := cmd.Start(); err != nil { + job, err := proc.StartTracked(cmd) + if err != nil { return nil, err } t := &stdioTransport{ name: s.Name, cmd: cmd, - job: proc.TrackTree(cmd), + job: job, stdin: stdin, stdout: bufio.NewReader(stdout), stderr: stderr, diff --git a/internal/proc/kill_other.go b/internal/proc/kill_other.go index 7634df749..aa5ad08ab 100644 --- a/internal/proc/kill_other.go +++ b/internal/proc/kill_other.go @@ -12,9 +12,12 @@ func KillTree(cmd *exec.Cmd) { _ = cmd.Process.Kill() } -// TrackTree is a no-op off Windows (returns 0); KillTracked then falls back to -// KillTree, which is sufficient where the platform reaps the child directly. -func TrackTree(_ *exec.Cmd) uintptr { return 0 } +// StartTracked starts cmd. Off Windows there is no Job Object to track it in — +// the platform reaps the child directly — so it just starts and returns a 0 +// handle, and KillTracked falls back to KillTree. +func StartTracked(cmd *exec.Cmd) (uintptr, error) { + return 0, cmd.Start() +} // KillTracked terminates cmd's process tree; the handle is unused off Windows. func KillTracked(cmd *exec.Cmd, _ uintptr) { KillTree(cmd) } diff --git a/internal/proc/kill_other_test.go b/internal/proc/kill_other_test.go index cf8065c57..cdacbcfd2 100644 --- a/internal/proc/kill_other_test.go +++ b/internal/proc/kill_other_test.go @@ -26,15 +26,16 @@ func TestKillTreeTerminatesChild(t *testing.T) { } func TestKillTrackedTerminatesChild(t *testing.T) { - if TrackTree(nil) != 0 { - t.Fatal("TrackTree is a no-op off Windows; want 0") - } cmd := exec.Command("sleep", "30") - if err := cmd.Start(); err != nil { - t.Fatalf("Start: %v", err) + job, err := StartTracked(cmd) + if err != nil { + t.Fatalf("StartTracked: %v", err) + } + if job != 0 { + t.Fatalf("StartTracked job = %d off Windows; want 0", job) } - KillTracked(cmd, 0) + KillTracked(cmd, job) done := make(chan error, 1) go func() { done <- cmd.Wait() }() diff --git a/internal/proc/kill_windows.go b/internal/proc/kill_windows.go index 5a7bc9d13..f139d7308 100644 --- a/internal/proc/kill_windows.go +++ b/internal/proc/kill_windows.go @@ -5,6 +5,7 @@ package proc import ( "os/exec" "strconv" + "syscall" "unsafe" "golang.org/x/sys/windows" @@ -24,14 +25,29 @@ func KillTree(cmd *exec.Cmd) { _ = cmd.Process.Kill() } -// TrackTree assigns cmd to a new Job Object set to terminate every process in -// it when the job handle is closed. A launcher's detached grandchild (e.g. the -// CodeGraph node daemon, which re-parents itself away from the launcher) stays -// in the job even though it leaves cmd's live child tree, so KillTracked — and, -// crucially, an abrupt reasonix exit, which closes the handle — still reaps it, -// where taskkill /T would miss it. Returns 0 on failure; the caller then relies -// on KillTree alone. -func TrackTree(cmd *exec.Cmd) uintptr { +// StartTracked starts cmd inside a new Job Object whose KILL_ON_JOB_CLOSE flag +// fells the whole tree — including a launcher's detached grandchild (cmd.exe → +// node.exe, as the CodeGraph daemon re-parents itself off the launcher) — when +// the handle closes via KillTracked or an abrupt reasonix exit. The child is +// created suspended and assigned to the job before it runs, so a fast shim can +// no longer exec its grandchild and exit before assignment, orphaning a node +// the job never captured (#3747). It is always resumed before returning, even +// when job assignment fails, so a child is never left wedged suspended. Returns +// the job handle, 0 if it could not be created — then KillTracked relies on +// KillTree alone. +func StartTracked(cmd *exec.Cmd) (uintptr, error) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.CreationFlags |= windows.CREATE_SUSPENDED + if err := cmd.Start(); err != nil { + return 0, err + } + defer resumeProcess(uint32(cmd.Process.Pid)) + return assignJob(cmd), nil +} + +func assignJob(cmd *exec.Cmd) uintptr { if cmd == nil || cmd.Process == nil { return 0 } @@ -62,8 +78,31 @@ func TrackTree(cmd *exec.Cmd) uintptr { return uintptr(job) } -// KillTracked terminates cmd's whole process tree. When job (from TrackTree) is -// non-zero, terminating it kills even detached descendants; the KillTree pass +// resumeProcess resumes every thread of pid. A CREATE_SUSPENDED process has a +// single suspended primary thread, so this releases it once the job is assigned. +func resumeProcess(pid uint32) { + snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0) + if err != nil { + return + } + defer func() { _ = windows.CloseHandle(snap) }() + var te windows.ThreadEntry32 + te.Size = uint32(unsafe.Sizeof(te)) + for err := windows.Thread32First(snap, &te); err == nil; err = windows.Thread32Next(snap, &te) { + if te.OwnerProcessID != pid { + continue + } + th, err := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, te.ThreadID) + if err != nil { + continue + } + _, _ = windows.ResumeThread(th) + _ = windows.CloseHandle(th) + } +} + +// KillTracked terminates cmd's whole process tree. When job (from StartTracked) +// is non-zero, terminating it kills even detached descendants; the KillTree pass // then catches anything spawned in the gap before the job was assigned. func KillTracked(cmd *exec.Cmd, job uintptr) { if job != 0 { diff --git a/internal/proc/kill_windows_test.go b/internal/proc/kill_windows_test.go index 5ae592012..f770e73ca 100644 --- a/internal/proc/kill_windows_test.go +++ b/internal/proc/kill_windows_test.go @@ -3,6 +3,7 @@ package proc import ( + "errors" "io" "os/exec" "testing" @@ -37,7 +38,7 @@ func TestKillTreeUnblocksWaitOnSurvivingGrandchild(t *testing.T) { } } -// TrackTree must create a Job Object for a started process, and KillTracked +// StartTracked must create a Job Object for the started process, and KillTracked // must take the whole tracked tree down (the job reaps even descendants a plain // taskkill /T would miss — see the codegraph daemon leak this guards against). func TestKillTrackedReapsTrackedTree(t *testing.T) { @@ -47,15 +48,14 @@ func TestKillTrackedReapsTrackedTree(t *testing.T) { if err != nil { t.Fatalf("StdoutPipe: %v", err) } - if err := cmd.Start(); err != nil { - t.Fatalf("Start: %v", err) + job, err := StartTracked(cmd) + if err != nil { + t.Fatalf("StartTracked: %v", err) } - go func() { _, _ = io.Copy(io.Discard, stdout) }() - - job := TrackTree(cmd) if job == 0 { - t.Fatal("TrackTree returned 0 — job object not created") + t.Fatal("StartTracked returned 0 — job object not created") } + go func() { _, _ = io.Copy(io.Discard, stdout) }() time.Sleep(500 * time.Millisecond) // let cmd.exe exec the ping grandchild into the job KillTracked(cmd, job) @@ -68,3 +68,26 @@ func TestKillTrackedReapsTrackedTree(t *testing.T) { t.Fatal("cmd.Wait blocked after KillTracked — tracked tree survived") } } + +// StartTracked creates the child suspended to win the job-assignment race, so it +// must resume it or the process hangs forever. A child that exits with a known +// code proves the resume fired: Wait returns that code instead of timing out. +func TestStartTrackedResumesSuspendedChild(t *testing.T) { + cmd := exec.Command("cmd", "/c", "exit", "7") + HideWindow(cmd) + if _, err := StartTracked(cmd); err != nil { + t.Fatalf("StartTracked: %v", err) + } + + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + var ee *exec.ExitError + if !errors.As(err, &ee) || ee.ExitCode() != 7 { + t.Fatalf("exit = %v, want exit status 7", err) + } + case <-time.After(8 * time.Second): + t.Fatal("cmd.Wait blocked — StartTracked left the child suspended") + } +} From 061ac30fcfcfd9c0a48e324da632a09edf1768bf Mon Sep 17 00:00:00 2001 From: lightfront Date: Wed, 10 Jun 2026 10:52:36 +0800 Subject: [PATCH 41/64] fix(desktop): convert \slashed to \not for KaTeX compatibility (#3750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): convert \\slashed to \\not for KaTeX compatibility KaTeX doesn't support the \\slashed command from the LaTeX 'slashed' package, which is commonly used in physics for Feynman slash notation (\\slashed{p}, \\slashed{\\partial}). Convert \\slashed{X} to \\not{X} before passing to KaTeX. The \\not command provides a similar visual effect (a slash through the character) and is supported by KaTeX. This prevents KaTeX parse errors when the model emits physics notation using \\slashed. Tests: 3 new cases added, 111/111 passing * fix(desktop): handle unbraced \\\\slashed forms like \\\\slashed\\\\epsilon(0) The previous fix only handled \\\\slashed{X} (braced form). User-reported issue: \\\\slashed\\\\epsilon(0) (Greek letter + function call, no braces) was not being converted and still showed as red KaTeX error. Extended the regex to handle two additional forms: - \\\\slashed X → \\\\not X (single letter, no braces) - \\\\slashed\\\\epsilon → \\\\not{\\\\epsilon} (backslash command) - \\\\slashed\\\\epsilon(0) → \\\\not{\\\\epsilon(0)} (backslash command + function) - \\\\slashed a → \\\\not a (single ASCII letter) This covers the common physics notation \\\\slashed{p} where the model sometimes forgets the braces around the argument. Tests: 2 new cases added, 113/113 math-golden passing. --------- Co-authored-by: Xingbo Zhao --- .../src/__tests__/math-golden.test.ts | 19 ++++++++++++++++++ .../frontend/src/components/latexNormalize.ts | 20 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/desktop/frontend/src/__tests__/math-golden.test.ts b/desktop/frontend/src/__tests__/math-golden.test.ts index e676bde0d..2d12bd8a9 100644 --- a/desktop/frontend/src/__tests__/math-golden.test.ts +++ b/desktop/frontend/src/__tests__/math-golden.test.ts @@ -133,6 +133,25 @@ eq(normalizeMath("\\(x^2\\)"), "$x^2$", "\\(…\\) → $…$"); eq(normalizeMath("\\[E=mc^2\\]"), "$$E=mc^2$$", "\\[…\\] → $$…$$"); eq(normalizeMath("\\\\[4pt]"), "\\\\[4pt]", "\\\\[ line-break spacing protected"); +// ── normalizeMath — \\slashed conversion (regression) ───────────────────────────── +// KaTeX doesn't support \\slashed (used in physics for Feynman slash notation), +// so convert it to \\not which has a similar visual effect. +check("\\\\slashed{p} is converted to \\not{p}", () => { + return normalizeMath("$\\\\slashed{p}$") === "$\\\\not{p}$"; +}); +check("\\\\slashed{\\\\partial} is converted to \\not{\\\\partial}", () => { + return normalizeMath("$\\\\slashed{\\\\partial}$") === "$\\\\not{\\\\partial}$"; +}); +check("\\\\slashed in prose context", () => { + return normalizeMath("The momentum $\\\\slashed{p}$ is conserved") === "The momentum $\\\\not{p}$ is conserved"; +}); +check("\\\\slashed\\\\epsilon(0) → \\not{\\epsilon(0)} (Greek + function)", () => { + return normalizeMath("$\\slashed\\epsilon(0)$") === "$\\not{\\epsilon(0)}$"; +}); +check("\\\\slashed a → \\not a (single letter)", () => { + return normalizeMath("$\\\\slashed a$") === "$\\\\not a$"; +}); + console.log("\nnormalizeMath — non-math dollar filtering"); eq(normalizeMath("costs $5$ today"), "costs $5$ today", "$5$ not math"); eq(normalizeMath("env $PATH$ here"), "env $PATH$ here", "$PATH$ not math"); diff --git a/desktop/frontend/src/components/latexNormalize.ts b/desktop/frontend/src/components/latexNormalize.ts index 0b94d4fbe..43b00d1aa 100644 --- a/desktop/frontend/src/components/latexNormalize.ts +++ b/desktop/frontend/src/components/latexNormalize.ts @@ -21,6 +21,26 @@ const TEXT_COMMANDS = new Set([ ]); export function latexNormalizeForKatex(source: string): string { + // Convert \slashed{X} → \not{X} and \slashed X → \not X. KaTeX doesn't + // support \slashed, but \not provides a similar visual effect (slash + // through the character). This is commonly used in physics for Feynman + // slash notation (\slashed{p}, \slashed{\partial}). + // Handles two forms: + // 1. Braced: \slashed{X} → \not{X} + // 2. Unbraced: \slashed X → \not X (single token, no spaces) + source = source.replace(/\\slashed\s*\{((?:[^{}]|\{[^{}]*\})*)\}/g, "\\not{$1}"); + // Also handle unbraced forms: + // \slashed\epsilon → \not{\epsilon} + // \slashed\epsilon(0) → \not{\epsilon(0)} + // \slashed a → \not a + // \slashed x → \not x + // Match a backslash command (optionally followed by (...) for function calls) + // or a single ASCII letter. Use a function so we can add braces around + // function-call forms. + source = source.replace(/\\slashed\s*(\\[A-Za-z]+(?:\([^)]*\))?|[A-Za-z])/g, (_match, inner) => { + return inner.includes("(") ? `\\not{${inner}}` : `\\not ${inner}`; + }); + let out = ""; let i = 0; From 94e53a3189574ca264a425d1379792ff41180973 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 9 Jun 2026 20:01:54 -0700 Subject: [PATCH 42/64] test(desktop): use single-backslash inputs in the slashed golden tests (#3761) The \slashed -> \not cases (#3750) wrote their braced inputs with a double backslash (\slashed{p}); real model output is a single one. The regex matched either way so the tests passed, but they exercised a form the model never emits. Switch them to single-backslash inputs and the eq() helper the surrounding golden tests already use. Co-authored-by: reasonix --- .../src/__tests__/math-golden.test.ts | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/desktop/frontend/src/__tests__/math-golden.test.ts b/desktop/frontend/src/__tests__/math-golden.test.ts index 2d12bd8a9..d70bc8980 100644 --- a/desktop/frontend/src/__tests__/math-golden.test.ts +++ b/desktop/frontend/src/__tests__/math-golden.test.ts @@ -133,24 +133,13 @@ eq(normalizeMath("\\(x^2\\)"), "$x^2$", "\\(…\\) → $…$"); eq(normalizeMath("\\[E=mc^2\\]"), "$$E=mc^2$$", "\\[…\\] → $$…$$"); eq(normalizeMath("\\\\[4pt]"), "\\\\[4pt]", "\\\\[ line-break spacing protected"); -// ── normalizeMath — \\slashed conversion (regression) ───────────────────────────── -// KaTeX doesn't support \\slashed (used in physics for Feynman slash notation), -// so convert it to \\not which has a similar visual effect. -check("\\\\slashed{p} is converted to \\not{p}", () => { - return normalizeMath("$\\\\slashed{p}$") === "$\\\\not{p}$"; -}); -check("\\\\slashed{\\\\partial} is converted to \\not{\\\\partial}", () => { - return normalizeMath("$\\\\slashed{\\\\partial}$") === "$\\\\not{\\\\partial}$"; -}); -check("\\\\slashed in prose context", () => { - return normalizeMath("The momentum $\\\\slashed{p}$ is conserved") === "The momentum $\\\\not{p}$ is conserved"; -}); -check("\\\\slashed\\\\epsilon(0) → \\not{\\epsilon(0)} (Greek + function)", () => { - return normalizeMath("$\\slashed\\epsilon(0)$") === "$\\not{\\epsilon(0)}$"; -}); -check("\\\\slashed a → \\not a (single letter)", () => { - return normalizeMath("$\\\\slashed a$") === "$\\\\not a$"; -}); +// ── normalizeMath — \slashed conversion (regression) ────────────────────────── +// KaTeX has no \slashed (Feynman slash notation); it is rewritten to \not. +eq(normalizeMath("$\\slashed{p}$"), "$\\not{p}$", "\\slashed{p} → \\not{p}"); +eq(normalizeMath("$\\slashed{\\partial}$"), "$\\not{\\partial}$", "\\slashed{\\partial} → \\not{\\partial}"); +eq(normalizeMath("The momentum $\\slashed{p}$ is conserved"), "The momentum $\\not{p}$ is conserved", "\\slashed in prose"); +eq(normalizeMath("$\\slashed\\epsilon(0)$"), "$\\not{\\epsilon(0)}$", "\\slashed\\epsilon(0) → \\not{\\epsilon(0)} (unbraced fn)"); +eq(normalizeMath("$\\slashed a$"), "$\\not a$", "\\slashed a → \\not a (unbraced letter)"); console.log("\nnormalizeMath — non-math dollar filtering"); eq(normalizeMath("costs $5$ today"), "costs $5$ today", "$5$ not math"); From 204eaf0cc64014b871a26d570fd51e40a5581c06 Mon Sep 17 00:00:00 2001 From: SivanCola Date: Wed, 10 Jun 2026 11:32:18 +0800 Subject: [PATCH 43/64] feat(desktop): themeable workspace UI, tool-approval modes, and /goal loop (#3752) Lands three changes from #3752: - Desktop: graphite UI redesign with slate/carbon/nocturne/amber theme directions (light+dark), Cmd-K command palette, and a status bar / composer / sidebar refresh. Default desktop appearance is now light. - Controller: separate tool approvals from business asks and plan approval. Posture is ask/auto/yolo; deny rules still block upstream, auto keeps explicit ask rules while auto-approving the writer fallback, and plan approval plus the ask tool stay user decisions in every mode. - Controller: opt-in /goal autonomous loop that self-continues up to 50 turns, driven by [goal:complete|blocked|continue] markers, bounded and cancellable, with a repeated-block stop. --- desktop/app.go | 247 +- desktop/frontend/package.json | 1 + .../check-browser-preview-stability.mjs | 371 + desktop/frontend/src/App.tsx | 923 +- .../src/components/AnchoredPopover.tsx | 106 +- desktop/frontend/src/components/AppChrome.tsx | 174 + .../src/components/CapabilitiesPanel.tsx | 19 +- .../src/components/CommandPalette.tsx | 26 +- desktop/frontend/src/components/Composer.tsx | 460 +- .../frontend/src/components/ContextPanel.tsx | 92 +- .../src/components/EffortSwitcher.tsx | 53 +- .../frontend/src/components/HistoryPanel.tsx | 69 +- .../frontend/src/components/MemoryPanel.tsx | 28 +- .../src/components/ModalCloseButton.tsx | 20 + .../frontend/src/components/ModelSwitcher.tsx | 44 +- .../src/components/NotificationCenter.tsx | 103 - .../frontend/src/components/ProcessCard.tsx | 8 +- .../frontend/src/components/ProjectTree.tsx | 284 +- .../src/components/ResizableDrawer.tsx | 6 +- .../frontend/src/components/SettingsPanel.tsx | 115 +- desktop/frontend/src/components/StatusBar.tsx | 130 +- .../src/components/StreamingIndicator.tsx | 93 - desktop/frontend/src/components/TabBar.tsx | 43 +- desktop/frontend/src/components/ToolCard.tsx | 5 +- desktop/frontend/src/components/Tooltip.tsx | 18 +- desktop/frontend/src/components/Welcome.tsx | 11 +- .../src/components/WorkspacePanel.tsx | 58 +- desktop/frontend/src/lib/bridge.ts | 569 +- desktop/frontend/src/lib/i18n.tsx | 7 +- desktop/frontend/src/lib/layoutPreferences.ts | 2 + .../frontend/src/lib/notificationCenter.ts | 106 - desktop/frontend/src/lib/theme.ts | 77 +- desktop/frontend/src/lib/types.ts | 73 +- desktop/frontend/src/lib/useController.ts | 89 +- .../frontend/src/lib/useMountTransition.ts | 97 + desktop/frontend/src/locales/en.ts | 137 +- desktop/frontend/src/locales/zh.ts | 143 +- desktop/frontend/src/styles.css | 9878 +++++++++++++---- desktop/settings_app.go | 12 +- desktop/tab_profile_test.go | 212 +- desktop/tabs.go | 752 +- desktop/tabs_telemetry_test.go | 53 + desktop/tabs_topic_test.go | 75 +- docs/SPEC.md | 34 + go.sum | 16 + internal/agent/ask.go | 12 +- internal/agent/ask_test.go | 24 + internal/cli/chat_tui.go | 108 +- internal/cli/cli.go | 9 +- internal/cli/complete.go | 1 + internal/cli/gitstatus.go | 2 +- internal/cli/statusline_test.go | 16 +- internal/config/config.go | 21 +- internal/config/edit.go | 2 +- internal/config/edit_test.go | 1 + internal/control/auto_plan.go | 6 +- internal/control/controller.go | 551 +- internal/control/goal_test.go | 151 + internal/control/input.go | 60 + internal/control/input_test.go | 50 + internal/control/yolo_test.go | 644 +- internal/i18n/i18n.go | 7 +- internal/i18n/messages_en.go | 11 +- internal/i18n/messages_zh.go | 11 +- internal/permission/permission.go | 7 +- internal/serve/index.html | 19 +- internal/serve/serve.go | 34 +- 67 files changed, 13663 insertions(+), 3923 deletions(-) create mode 100644 desktop/frontend/scripts/check-browser-preview-stability.mjs create mode 100644 desktop/frontend/src/components/AppChrome.tsx create mode 100644 desktop/frontend/src/components/ModalCloseButton.tsx delete mode 100644 desktop/frontend/src/components/NotificationCenter.tsx delete mode 100644 desktop/frontend/src/components/StreamingIndicator.tsx delete mode 100644 desktop/frontend/src/lib/notificationCenter.ts create mode 100644 desktop/frontend/src/lib/useMountTransition.ts create mode 100644 desktop/tabs_telemetry_test.go create mode 100644 internal/control/goal_test.go diff --git a/desktop/app.go b/desktop/app.go index 203de28de..f6fb677ec 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -376,6 +376,11 @@ func (a *App) restoreOrBuildTabs() { tab.model = entry.Model tab.effort = cloneStringPtr(entry.Effort) tab.mode = persistedTabMode(entry.Mode) + tab.goal = strings.TrimSpace(entry.Goal) + tab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode) + if tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) { + tab.toolApprovalMode = control.ToolApprovalYolo + } tab.SessionPath = strings.TrimSpace(entry.SessionPath) tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx} a.mu.Lock() @@ -418,13 +423,14 @@ func (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab func (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab { return &WorkspaceTab{ - ID: id, - Scope: scope, - WorkspaceRoot: workspaceRoot, - TopicID: topicID, - TopicTitle: topicTitleForTab(scope, workspaceRoot, topicID), - mode: "normal", - disabledMCP: map[string]ServerView{}, + ID: id, + Scope: scope, + WorkspaceRoot: workspaceRoot, + TopicID: topicID, + TopicTitle: topicTitleForTab(scope, workspaceRoot, topicID), + mode: "normal", + toolApprovalMode: control.ToolApprovalAsk, + disabledMCP: map[string]ServerView{}, } } @@ -601,18 +607,21 @@ func (a *App) ApproveTabWithScope(tabID, id string, allow, session, persist bool } } -// SetPlanMode toggles read-only plan mode. +// SetPlanMode toggles the read-only plan axis while preserving the current +// tool-auto-approval axis. func (a *App) SetPlanMode(on bool) { - if on { - a.SetModeForTab("", "plan") - return - } - a.SetModeForTab("", "normal") + a.setPlanModeForTab("", on) } -// SetMode applies a composer gating mode ("plan" | "yolo" | anything else = +func (a *App) setPlanModeForTab(tabID string, on bool) { + current := a.currentModeForTab(tabID) + a.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current))) +} + +// SetMode applies a composer gating mode ("plan" | "yolo" | "plan-yolo" | +// anything else = // normal) in one call, so a turn submitted right after the switch can't race a -// half-applied SetPlanMode/SetBypass pair. +// half-applied plan/tool-auto-approval pair. func (a *App) SetMode(mode string) { a.SetModeForTab("", mode) } @@ -626,10 +635,18 @@ func (a *App) SetModeForTab(tabID, mode string) { return } tab.mode = normalized + tab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode) + if tabModeHasAutoApproveTools(normalized) { + tab.toolApprovalMode = control.ToolApprovalYolo + } else if tab.toolApprovalMode == control.ToolApprovalYolo { + tab.toolApprovalMode = control.ToolApprovalAsk + } ctrl := tab.Ctrl + approvalMode := tab.toolApprovalMode tabIDForSave := tab.ID a.mu.Unlock() applyTabModeToController(ctrl, normalized) + applyTabToolApprovalModeToController(ctrl, approvalMode) a.mu.Lock() if a.tabs[tabIDForSave] == tab { a.saveTabsLocked() @@ -646,11 +663,81 @@ func applyTabModeToController(ctrl *control.Controller, mode string) { ctrl.SetMode(true, false) case "yolo": ctrl.SetMode(false, true) + case "plan-yolo": + ctrl.SetMode(true, true) default: ctrl.SetMode(false, false) } } +func applyTabToolApprovalModeToController(ctrl *control.Controller, mode string) { + if ctrl == nil { + return + } + ctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode)) +} + +func (a *App) currentModeForTab(tabID string) string { + a.mu.RLock() + tab := a.tabByIDLocked(tabID) + mode := "normal" + if tab != nil { + mode = currentTabMode(tab) + } + a.mu.RUnlock() + return mode +} + +func normalizeCollaborationMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "plan": + return "plan" + case "goal": + return "goal" + default: + return "normal" + } +} + +func (a *App) SetCollaborationMode(mode string) { + a.SetCollaborationModeForTab("", mode) +} + +func (a *App) SetCollaborationModeForTab(tabID, mode string) { + mode = normalizeCollaborationMode(mode) + a.mu.Lock() + tab := a.tabByIDLocked(tabID) + if tab == nil { + a.mu.Unlock() + return + } + approvalMode := currentTabToolApprovalMode(tab) + switch mode { + case "plan": + tab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo) + tab.goal = "" + case "goal": + tab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo) + default: + tab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo) + tab.goal = "" + } + ctrl := tab.Ctrl + goal := tab.goal + plan := tabModeHasPlan(tab.mode) + tabIDForSave := tab.ID + a.mu.Unlock() + if ctrl != nil { + ctrl.SetPlanMode(plan) + ctrl.SetGoal(goal) + } + a.mu.Lock() + if a.tabs[tabIDForSave] == tab { + a.saveTabsLocked() + } + a.mu.Unlock() +} + // QuestionAnswer is the frontend's reply to one question in an ask_request. type QuestionAnswer struct { QuestionID string `json:"questionId"` @@ -1553,12 +1640,16 @@ func (a *App) jobsForCtrl(ctrl *control.Controller, out []JobView) []JobView { // Meta describes the session for the frontend's header and status line. type Meta struct { - Label string `json:"label"` - Ready bool `json:"ready"` - StartupErr string `json:"startupErr,omitempty"` - EventChannel string `json:"eventChannel"` - Cwd string `json:"cwd"` - Bypass bool `json:"bypass"` // YOLO mode on (auto-approve every tool call) + Label string `json:"label"` + Ready bool `json:"ready"` + StartupErr string `json:"startupErr,omitempty"` + EventChannel string `json:"eventChannel"` + Cwd string `json:"cwd"` + AutoApproveTools bool `json:"autoApproveTools"` + Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval + ToolApprovalMode string `json:"toolApprovalMode"` + Goal string `json:"goal,omitempty"` + GoalStatus string `json:"goalStatus,omitempty"` } // Meta reports the model label, readiness, any startup error, the working @@ -1577,25 +1668,110 @@ func (a *App) MetaForTab(tabID string) Meta { if cwd == "" { cwd, _ = os.Getwd() } + autoApproveTools := tab.Ctrl != nil && tab.Ctrl.AutoApproveTools() + toolApprovalMode := currentTabToolApprovalMode(tab) + goal := currentTabGoal(tab) + goalStatus := currentTabGoalStatus(tab) return Meta{ - Label: tab.Label, - Ready: tab.Ready, - StartupErr: tab.StartupErr, - EventChannel: eventChannel, - Cwd: cwd, - Bypass: tab.Ctrl != nil && tab.Ctrl.Bypass(), + Label: tab.Label, + Ready: tab.Ready, + StartupErr: tab.StartupErr, + EventChannel: eventChannel, + Cwd: cwd, + AutoApproveTools: autoApproveTools, + Bypass: autoApproveTools, + ToolApprovalMode: toolApprovalMode, + Goal: goal, + GoalStatus: goalStatus, } } -// SetBypass toggles YOLO mode for the session: auto-approve every tool call -// (writers and bash run without asking). Deny rules still apply. Runtime-only — -// not written to config, so it resets on relaunch. -func (a *App) SetBypass(on bool) { +func (a *App) SetGoal(goal string) { + a.SetGoalForTab("", goal) +} + +func (a *App) SetGoalForTab(tabID, goal string) { + goal = strings.TrimSpace(goal) + a.mu.Lock() + tab := a.tabByIDLocked(tabID) + if tab == nil { + a.mu.Unlock() + return + } + tab.goal = goal + if goal != "" { + tab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo) + } + ctrl := tab.Ctrl + plan := tabModeHasPlan(tab.mode) + tabIDForSave := tab.ID + a.mu.Unlock() + if ctrl != nil { + ctrl.SetPlanMode(plan) + ctrl.SetGoal(goal) + } + a.mu.Lock() + if a.tabs[tabIDForSave] == tab { + a.saveTabsLocked() + } + a.mu.Unlock() +} + +func (a *App) ClearGoal() { + a.SetGoal("") +} + +func (a *App) ClearGoalForTab(tabID string) { + a.SetGoalForTab(tabID, "") +} + +// SetAutoApproveTools toggles YOLO/full-access tool auto-approval: +// approval-gated tool calls run without asking, while ask questions and plan +// approvals still wait for the user. Runtime-only — not written to config. +func (a *App) SetAutoApproveTools(on bool) { if on { - a.SetModeForTab("", "yolo") + a.SetToolApprovalModeForTab("", control.ToolApprovalYolo) return } - a.SetModeForTab("", "normal") + a.SetToolApprovalModeForTab("", control.ToolApprovalAsk) +} + +func (a *App) setAutoApproveToolsForTab(tabID string, on bool) { + if on { + a.SetToolApprovalModeForTab(tabID, control.ToolApprovalYolo) + return + } + a.SetToolApprovalModeForTab(tabID, control.ToolApprovalAsk) +} + +// SetBypass is the legacy Wails binding for SetAutoApproveTools. +func (a *App) SetBypass(on bool) { + a.SetAutoApproveTools(on) +} + +func (a *App) SetToolApprovalMode(mode string) { + a.SetToolApprovalModeForTab("", mode) +} + +func (a *App) SetToolApprovalModeForTab(tabID, mode string) { + mode = normalizeToolApprovalMode(mode) + a.mu.Lock() + tab := a.tabByIDLocked(tabID) + if tab == nil { + a.mu.Unlock() + return + } + tab.toolApprovalMode = mode + tab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo) + ctrl := tab.Ctrl + tabIDForSave := tab.ID + a.mu.Unlock() + applyTabToolApprovalModeToController(ctrl, mode) + a.mu.Lock() + if a.tabs[tabIDForSave] == tab { + a.saveTabsLocked() + } + a.mu.Unlock() } // CommandInfo describes one available slash command for the composer's "/" menu. @@ -1616,6 +1792,7 @@ func (a *App) Commands() []CommandInfo { {Name: "model", Description: i18n.M.CmdModel, Kind: "builtin"}, {Name: "effort", Description: i18n.M.CmdEffort, Kind: "builtin"}, {Name: "memory", Description: i18n.M.CmdMemory, Kind: "builtin"}, + {Name: "goal", Description: i18n.M.CmdGoal, Kind: "builtin"}, {Name: "remember", Description: i18n.M.CmdRemember, Kind: "builtin"}, {Name: "mcp", Description: i18n.M.CmdMcp, Kind: "builtin"}, {Name: "hooks", Description: i18n.M.CmdHooks, Kind: "builtin"}, @@ -2874,6 +3051,8 @@ func (a *App) SetModelForTab(tabID, name string) error { a.mu.Unlock() newCtrl.EnableInteractiveApproval() applyTabModeToController(newCtrl, tab.mode) + applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode) + newCtrl.SetGoal(tab.goal) path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label()) if len(carried) > 0 { @@ -2966,6 +3145,8 @@ func (a *App) SetEffortForTab(tabID, level string) error { a.mu.Unlock() newCtrl.EnableInteractiveApproval() applyTabModeToController(newCtrl, tab.mode) + applyTabToolApprovalModeToController(newCtrl, tab.toolApprovalMode) + newCtrl.SetGoal(tab.goal) path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label()) if len(carried) > 0 { newCtrl.Resume(&agent.Session{Messages: carried}, path) diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index ba0974593..d71a7b5dc 100644 --- a/desktop/frontend/package.json +++ b/desktop/frontend/package.json @@ -8,6 +8,7 @@ "build": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css && tsc --noEmit && vite build", "preview": "vite preview", "check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css", + "check:browser-preview": "node scripts/check-browser-preview-stability.mjs", "test:todo-visibility": "node scripts/test-todo-visibility.mjs", "typecheck": "tsc --noEmit", "test": "tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/provider-model-refresh.test.ts", diff --git a/desktop/frontend/scripts/check-browser-preview-stability.mjs b/desktop/frontend/scripts/check-browser-preview-stability.mjs new file mode 100644 index 000000000..f7e3c9b65 --- /dev/null +++ b/desktop/frontend/scripts/check-browser-preview-stability.mjs @@ -0,0 +1,371 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const frontendRoot = path.resolve(scriptDir, ".."); + +function read(rel) { + return fs.readFileSync(path.join(frontendRoot, rel), "utf8"); +} + +function fail(message) { + console.error(message); + process.exitCode = 1; +} + +function mustNotContain(rel, source, needle, reason) { + if (source.includes(needle)) { + fail(`${rel}: found forbidden "${needle}" (${reason})`); + } +} + +function mustContain(rel, source, needle, reason) { + if (!source.includes(needle)) { + fail(`${rel}: missing "${needle}" (${reason})`); + } +} + +const anchoredPopover = read("src/components/AnchoredPopover.tsx"); +mustNotContain( + "src/components/AnchoredPopover.tsx", + anchoredPopover, + "anchored-popover__backdrop", + "non-modal popovers must not render a transparent page-covering click layer", +); +mustNotContain( + "src/components/AnchoredPopover.tsx", + anchoredPopover, + "document.querySelector(\"[data-anchored-popover='active']\")", + "popover positioning must measure its own ref, not the first matching portal in the document", +); +mustContain( + "src/components/AnchoredPopover.tsx", + anchoredPopover, + "const popoverRef = useRef(null);", + "popover positioning and outside-click handling must be scoped to the current instance", +); +mustContain( + "src/components/AnchoredPopover.tsx", + anchoredPopover, + "popoverRef.current?.getBoundingClientRect()", + "popover positioning must use the current popover instance", +); + +const statusBar = read("src/components/StatusBar.tsx"); +mustNotContain( + "src/components/StatusBar.tsx", + statusBar, + "modelsw__backdrop", + "status-bar popovers must not cover the whole viewport after opening", +); +mustContain( + "src/components/StatusBar.tsx", + statusBar, + "wrapRef.current?.contains(target)", + "status-bar popover outside-click handling must be scoped to the wrapper", +); + +const composer = read("src/components/Composer.tsx"); +mustContain( + "src/components/Composer.tsx", + composer, + "composer-action-trigger", + "more actions must live behind the compact plus affordance", +); +mustContain( + "src/components/Composer.tsx", + composer, + "composer-mode-chip--plan", + "active plan mode must surface as a dismissible composer chip", +); +mustContain( + "src/components/Composer.tsx", + composer, + "composer-mode-chip--goal", + "active goal mode must surface as a dismissible composer chip", +); +mustContain( + "src/components/Composer.tsx", + composer, + "composer-intent-menu__item${planModeOn", + "plan mode must be created from the plus menu", +); +mustContain( + "src/components/Composer.tsx", + composer, + "composer-modebar--approval", + "tool approval must use a direct segmented control", +); +mustNotContain( + "src/components/Composer.tsx", + composer, + "composer-modebar--collaboration", + "collaboration modes must not occupy the always-visible segmented control", +); +mustNotContain( + "src/components/Composer.tsx", + composer, + "composer-intent-chip", + "plan/goal chips must not return to the always-visible composer row", +); +mustNotContain( + "src/components/Composer.tsx", + composer, + "composer-plan-toggle", + "plan mode must not be an always-visible standalone control", +); +mustNotContain( + "src/components/Composer.tsx", + composer, + "composer-access-trigger", + "tool approval must not regress to a single dropdown trigger", +); + +const projectTree = read("src/components/ProjectTree.tsx"); +mustContain( + "src/components/ProjectTree.tsx", + projectTree, + "GLOBAL_PROJECT_ORDER_KEY", + "Global must participate in project tree reorder through a stable virtual key", +); +mustContain( + "src/components/ProjectTree.tsx", + projectTree, + "draggable={draggableProject}", + "project rows should be directly draggable for reorder", +); +mustContain( + "src/components/ProjectTree.tsx", + projectTree, + "target.closest(\".project-tree__action-slot\")", + "project row drag must not start from the new-topic action area", +); +mustNotContain( + "src/components/ProjectTree.tsx", + projectTree, + "project-tree__drag-handle", + "project reorder should not expose a separate drag handle", +); +mustContain( + "src/components/ProjectTree.tsx", + projectTree, + "manuallyCollapsedRef", + "project-tree async refresh must read the latest manual collapse state", +); +mustContain( + "src/components/ProjectTree.tsx", + projectTree, + "project-tree__collapse-all", + "project tree needs a dedicated collapse-all affordance", +); +mustContain( + "src/components/ProjectTree.tsx", + projectTree, + "collapseSnapshot", + "collapse-all must be reversible to the previous tree view", +); +mustContain( + "src/components/ProjectTree.tsx", + projectTree, + "collapsibleFolderKeys", + "collapse-all must target every expandable project folder key", +); + +const styles = read("src/styles.css"); +mustNotContain( + "src/styles.css", + styles, + ".anchored-popover__backdrop", + "stale backdrop CSS can hide accidental page-covering layers", +); +mustNotContain( + "src/styles.css", + styles, + ".modelsw__backdrop", + "stale backdrop CSS can hide accidental page-covering layers", +); +mustNotContain( + "src/styles.css", + styles, + ".composer-access-trigger", + "stale single approval dropdown CSS should not survive the segmented control migration", +); +mustContain( + "src/styles.css", + styles, + ".composer-modebar--approval", + "approval segmented control needs dedicated state styling", +); +mustContain( + "src/styles.css", + styles, + ".composer-mode-chip", + "active plan/goal chips need shared dismiss styling", +); +mustContain( + "src/styles.css", + styles, + ".composer-intent-switch", + "plan/goal plus menu needs explicit switch feedback", +); +mustNotContain( + "src/styles.css", + styles, + ".project-tree__drag-handle", + "project reorder should not expose a separate drag handle", +); +mustContain( + "src/styles.css", + styles, + ".project-tree__folder--draggable", + "project reorder needs whole-row drag cursor feedback", +); +mustContain( + "src/styles.css", + styles, + ".project-tree__collapse-all", + "project collapse-all button must keep its compact icon affordance", +); +mustContain( + "src/styles.css", + styles, + ".project-tree__collapse-all--restore", + "restorable project tree state must have a distinct visual affordance", +); +mustContain( + "src/styles.css", + styles, + "grid-template-rows 0.2s cubic-bezier", + "project folder collapse needs an intentional height transition", +); + +const app = read("src/App.tsx"); +mustContain( + "src/App.tsx", + app, + "const [rightDockMode, setRightDockMode] = useState(\"context\");", + "the right dock should open on overview as the first information layer", +); +mustContain( + "src/App.tsx", + app, + "openWorkspacePanel(\"context\");", + "reopening the right dock should return to the overview entry point", +); +mustContain( + "src/App.tsx", + app, + "{SHOW_CONTEXT_DOCK && (\n runningMock && mockTopicIsRunning(topicId);", + "all mock runtime state must stay behind the explicit running scenario", +); +mustContain( + "src/lib/bridge.ts", + bridge, + "if (!runningMock) return;", + "mock runtime event injection must be disabled for the default browser preview", +); +mustNotContain( + "src/lib/bridge.ts", + bridge, + "running: mockTopicIsRunning(", + "tab running state must not bypass the explicit running scenario", +); +for (const status of ["streaming", "thinking", "waiting_confirmation"]) { + mustContain( + "src/lib/bridge.ts", + bridge, + `status: runningMock ? "${status}"`, + `mock ${status} state must not appear in the default browser preview`, + ); +} +mustContain( + "src/lib/bridge.ts", + bridge, + "running: runningMock && mockTopicIsRunning(\"topic_p3b_pd\")", + "mock tab running state must not be active in the default browser preview", +); + +if (process.exitCode) { + process.exit(process.exitCode); +} + +console.log("Browser preview stability check passed"); diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index 66dae8311..681cb0f7d 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -1,23 +1,19 @@ -import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, KeyboardEvent, PointerEvent as ReactPointerEvent } from "react"; import { ShellExpandProvider, useShellExpand } from "./lib/shellExpand"; import { + Activity, + Command, Download, SquarePen, - CircleGauge, FileText, FileJson, GitBranch, History, Settings as SettingsIcon, Pencil, - PanelLeftClose, - PanelLeftOpen, - PanelRightClose, - PanelRightOpen, Trash2, } from "lucide-react"; -import logoWordmark from "./assets/logo-wordmark.svg"; import { useToast } from "./lib/toast"; import { asArray } from "./lib/array"; import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, t, useI18n, useT } from "./lib/i18n"; @@ -30,6 +26,7 @@ import { ApprovalModal } from "./components/ApprovalModal"; import { AskCard } from "./components/AskCard"; import { StatusBar } from "./components/StatusBar"; import { HistoryPanel } from "./components/HistoryPanel"; +import { CommandPalette, type PaletteItem } from "./components/CommandPalette"; import { SettingsPanel } from "./components/SettingsPanel"; import { UpdateBanner } from "./components/UpdateBanner"; import { ContextPanel } from "./components/ContextPanel"; @@ -37,13 +34,27 @@ import { WorkspacePanel } from "./components/WorkspacePanel"; import { Tooltip } from "./components/Tooltip"; import { StartupSplash, shouldShowStartupSplash } from "./components/StartupSplash"; import { OnboardingOverlay } from "./components/OnboardingOverlay"; -import { TabBar } from "./components/TabBar"; +import { AppChrome } from "./components/AppChrome"; import { ProjectTree } from "./components/ProjectTree"; import { CopyButton } from "./components/CopyButton"; -import { CommandPalette, type PaletteItem } from "./components/CommandPalette"; import { parseTodos } from "./lib/tools"; import { shouldShowTodoPanel } from "./lib/todoVisibility"; -import type { ComposerInsertRequest, Meta, Mode, SessionMeta, SettingsTab, TabMeta } from "./lib/types"; +import { + modeHasAutoApproveTools, + modeHasPlan, + modeFromAxes, + normalizeCollaborationMode, + normalizeMode, + normalizeToolApprovalMode, + type CollaborationMode, + type ComposerInsertRequest, + type Mode, + type ProjectNode, + type SessionMeta, + type SettingsTab, + type TabMeta, + type ToolApprovalMode, +} from "./lib/types"; import { loadLayoutSize, saveLayoutSize } from "./lib/layoutPreferences"; import { applyTheme, @@ -54,35 +65,33 @@ import { normalizeThemePreference, normalizeThemeStyleForTheme, readLegacyThemePreference, - themeForStyle, type Theme, } from "./lib/theme"; import { applyTextSize, DEFAULT_TEXT_SIZE, getTextSize, nextTextSize } from "./lib/textSize"; import { useWindowStatePersistence } from "./lib/windowState"; +import logoWordmark from "./assets/logo-wordmark.svg"; const SIDEBAR_COLLAPSED_KEY = "reasonix.sidebar.collapsed"; const SIDEBAR_DEFAULT_WIDTH = 264; -const SIDEBAR_DEFAULT_RATIO = 0.175; -const SIDEBAR_MIN_WIDTH = 228; -const SIDEBAR_MAX_WIDTH = 420; +const SIDEBAR_MIN_WIDTH = 248; +const SIDEBAR_MAX_WIDTH = 300; +const SIDEBAR_VIEWPORT_RATIO = 0.18; const CHAT_MIN_WIDTH = 400; +const CHAT_DOCKED_MIN_WIDTH = 640; const WORKSPACE_RESIZER_WIDTH = 8; function isThemeMode(value: string): value is Theme { return value === "auto" || value === "light" || value === "dark"; } -const CONTEXT_PANEL_MIN_WIDTH = 340; -const RIGHT_DOCK_MIN_WIDTH = CONTEXT_PANEL_MIN_WIDTH; -const RIGHT_DOCK_CONTEXT_WIDTH = 380; -const RIGHT_DOCK_TREE_DEFAULT_WIDTH = 320; -const RIGHT_DOCK_TREE_DEFAULT_RATIO = 0.25; -const RIGHT_DOCK_TREE_MIN_WIDTH = 260; +const RIGHT_DOCK_TREE_DEFAULT_WIDTH = 300; +const RIGHT_DOCK_TREE_MIN_WIDTH = 300; const RIGHT_DOCK_TREE_MAX_WIDTH = 560; -const RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH = 640; +const RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH = 660; +const RIGHT_DOCK_PREVIEW_MIN_WIDTH = RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH; const RIGHT_DOCK_MAX_WIDTH = 860; type RightDockMode = "context" | "files" | "changed"; -const SHOW_CONTEXT_DOCK = false; +const SHOW_CONTEXT_DOCK = true; type HistoryScopeFilter = { scope: "global" | "project"; workspaceRoot: string }; type DesktopPlatform = "darwin" | "windows" | "linux"; type HistoryViewState = @@ -94,37 +103,23 @@ function clampSidebarWidth(width: number): number { return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, Math.round(width))); } -function clampRightDockWidth(width: number): number { - return Math.min(RIGHT_DOCK_MAX_WIDTH, Math.max(RIGHT_DOCK_MIN_WIDTH, Math.round(width))); +function clampRightDockPreviewWidth(width: number): number { + return Math.min(RIGHT_DOCK_MAX_WIDTH, Math.max(RIGHT_DOCK_PREVIEW_MIN_WIDTH, Math.round(width))); } function clampRightDockTreeWidth(width: number): number { return Math.min(RIGHT_DOCK_TREE_MAX_WIDTH, Math.max(RIGHT_DOCK_TREE_MIN_WIDTH, Math.round(width))); } -function viewportWidthFallback(): number { - if (typeof window === "undefined") return 0; - const width = Math.round(window.innerWidth || 0); - return Number.isFinite(width) && width > 0 ? width : 0; -} - function defaultSidebarWidth(): number { - const width = viewportWidthFallback(); - if (width <= 0) return SIDEBAR_DEFAULT_WIDTH; - return clampSidebarWidth(width * SIDEBAR_DEFAULT_RATIO); + if (typeof window !== "undefined") { + return clampSidebarWidth(window.innerWidth * SIDEBAR_VIEWPORT_RATIO); + } + return SIDEBAR_DEFAULT_WIDTH; } function defaultRightDockTreeWidth(): number { - const width = viewportWidthFallback(); - if (width <= 0) return RIGHT_DOCK_TREE_DEFAULT_WIDTH; - return clampRightDockTreeWidth(width * RIGHT_DOCK_TREE_DEFAULT_RATIO); -} - -function resolveRightDockWidth(mainWidth: number, desiredDockWidth: number, minWidth: number): number { - const budget = Math.max(0, Math.round(mainWidth) - CHAT_MIN_WIDTH - WORKSPACE_RESIZER_WIDTH); - if (budget < minWidth) return 0; - const desired = Math.min(RIGHT_DOCK_MAX_WIDTH, Math.max(minWidth, Math.round(desiredDockWidth))); - return Math.min(budget, desired); + return RIGHT_DOCK_TREE_DEFAULT_WIDTH; } function loadSidebarCollapsed(): boolean { @@ -146,11 +141,11 @@ function saveSidebarCollapsed(collapsed: boolean): void { } function loadSidebarWidth(): number { - return loadLayoutSize("sidebarWidth", defaultSidebarWidth(), clampSidebarWidth); + return loadLayoutSize("sidebarWidthGraphite", defaultSidebarWidth(), clampSidebarWidth); } function saveSidebarWidth(width: number): void { - saveLayoutSize("sidebarWidth", width, clampSidebarWidth); + saveLayoutSize("sidebarWidthGraphite", width, clampSidebarWidth); } function normalizeDesktopPlatform(value: string): DesktopPlatform { @@ -184,11 +179,11 @@ function saveRightDockTreeWidth(width: number): void { } function loadRightDockPreviewWidth(): number { - return loadLayoutSize("rightDockPreviewWidth", RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH, clampRightDockWidth); + return loadLayoutSize("rightDockPreviewWidth", RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH, clampRightDockPreviewWidth); } function saveRightDockPreviewWidth(width: number): void { - saveLayoutSize("rightDockPreviewWidth", width, clampRightDockWidth); + saveLayoutSize("rightDockPreviewWidth", width, clampRightDockPreviewWidth); } function tabWorkspaceTitle(tab?: TabMeta): string { @@ -205,21 +200,17 @@ function topicTitle(tab?: TabMeta): string { return topic === workspaceTitle ? workspaceTitle : `${workspaceTitle} / ${topic}`; } +function topicDisplayTitle(tab?: TabMeta): string { + if (!tab) return "Global"; + return tab.topicTitle || (tab.scope === "global" ? tabWorkspaceTitle(tab) : "Untitled"); +} + function topicScopeLabel(tab?: TabMeta): string { if (!tab) return t("scope.global"); if (tab.scope === "global") return tab.workspaceName || t("scope.global"); return t("scope.project", { name: tab.workspaceName || tab.workspaceRoot || "Project" }); } -function appChromeScopeLabel(tab?: TabMeta, meta?: Meta): string { - if (tab?.scope === "project" || tab?.scope === "global") return tabWorkspaceTitle(tab); - return workspaceDisplayName(meta?.cwd) || meta?.label || "Global"; -} - -function normalizeModeValue(mode?: string): Mode { - return mode === "plan" || mode === "yolo" ? mode : "normal"; -} - function sessionsForScope(sessions: SessionMeta[], filter: HistoryScopeFilter): SessionMeta[] { if (filter.scope === "project") { return sessions.filter((session) => session.scope === "project" && session.workspaceRoot === filter.workspaceRoot); @@ -227,6 +218,23 @@ function sessionsForScope(sessions: SessionMeta[], filter: HistoryScopeFilter): return sessions.filter((session) => (session.scope || "global") === "global"); } +function activeTopicTurnsFromTree(nodes: ProjectNode[], tab?: TabMeta): number | undefined { + if (!tab?.topicId) return undefined; + const activeScope = tab.scope === "global" ? "global" : "project"; + const stack = [...nodes]; + while (stack.length > 0) { + const node = stack.shift(); + if (!node) continue; + if (node.kind === "topic" || node.kind === "global_topic") { + const nodeScope = node.kind === "global_topic" ? "global" : "project"; + const sameRoot = nodeScope === "global" || node.root === tab.workspaceRoot; + if (node.topicId === tab.topicId && nodeScope === activeScope && sameRoot) return node.turns ?? 0; + } + if (node.children?.length) stack.push(...node.children); + } + return undefined; +} + function workspaceDisplayName(path?: string): string { if (!path) return ""; const parts = path.split(/[/\\]/).filter(Boolean); @@ -371,7 +379,10 @@ export default function App() { approve, answerQuestion, setControllerMode, - newSession, + setCollaborationMode: setControllerCollaborationMode, + setToolApprovalMode: setControllerToolApprovalMode, + setGoal: setControllerGoal, + clearGoal: clearControllerGoal, listSessions, listTrashedSessions, resumeSession, @@ -396,6 +407,9 @@ export default function App() { const { locale, setPref: setLocalePref } = useI18n(); const t = useT(); const [modesByTab, setModesByTab] = useState>({}); + const [collaborationModesByTab, setCollaborationModesByTab] = useState>({}); + const [toolApprovalModesByTab, setToolApprovalModesByTab] = useState>({}); + const [goalsByTab, setGoalsByTab] = useState>({}); const [tabMetas, setTabMetas] = useState([]); const [tabOrderIds, setTabOrderIds] = useState([]); const [tabRevealSignal, setTabRevealSignal] = useState(0); @@ -405,6 +419,8 @@ export default function App() { const [needsOnboarding, setNeedsOnboarding] = useState(null); const [settingsTarget, setSettingsTarget] = useState(null); const [histView, setHistView] = useState(null); + const [paletteOpen, setPaletteOpen] = useState(false); + const [paletteSessions, setPaletteSessions] = useState([]); const { showToast } = useToast(); const [sidebarCollapsed, setSidebarCollapsed] = useState(loadSidebarCollapsed); const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth); @@ -413,23 +429,81 @@ export default function App() { const [rightDockTreeWidth, setRightDockTreeWidth] = useState(loadRightDockTreeWidth); const [rightDockPreviewWidth, setRightDockPreviewWidth] = useState(loadRightDockPreviewWidth); const [workspacePreviewActive, setWorkspacePreviewActive] = useState(false); + const [contextDetailActive, setContextDetailActive] = useState(false); const [workspacePanelResizing, setWorkspacePanelResizing] = useState(false); const [workspacePanelMaximized, setWorkspacePanelMaximized] = useState(false); - const [rightDockMode, setRightDockMode] = useState("files"); + const [rightDockMode, setRightDockMode] = useState("context"); const [dockRefreshKey, setDockRefreshKey] = useState(0); const [projectRevision, setProjectRevision] = useState(0); const [composerInsertRequest, setComposerInsertRequest] = useState(null); + const [transientOverlayDismissSignal, setTransientOverlayDismissSignal] = useState(0); const [desktopPlatform, setDesktopPlatform] = useState(detectBrowserPlatform); const [renamingTopicId, setRenamingTopicId] = useState(null); const [topicTitleDraft, setTopicTitleDraft] = useState(""); const [topicExportOpen, setTopicExportOpen] = useState(false); - const [paletteOpen, setPaletteOpen] = useState(false); + const [sidebarTogglePressed, setSidebarTogglePressed] = useState(false); + const [workspaceTogglePressed, setWorkspaceTogglePressed] = useState(false); + const [savedTopicTurnCount, setSavedTopicTurnCount] = useState(undefined); const topicRenameSkipCommitRef = useRef(false); const topicRenameCommitHandledRef = useRef(false); + const appRef = useRef(null); + const sidebarTogglePressTimerRef = useRef(null); + const workspaceTogglePressTimerRef = useRef(null); // Persist window geometry across launches. useWindowStatePersistence(); + const closeTransientOverlays = useCallback(() => { + setTransientOverlayDismissSignal((signal) => signal + 1); + }, []); + + const pulseSidebarToggle = useCallback(() => { + if (typeof window === "undefined") return; + if (sidebarTogglePressTimerRef.current !== null) { + window.clearTimeout(sidebarTogglePressTimerRef.current); + } + setSidebarTogglePressed(true); + sidebarTogglePressTimerRef.current = window.setTimeout(() => { + sidebarTogglePressTimerRef.current = null; + setSidebarTogglePressed(false); + }, 260); + }, []); + + const pulseWorkspaceToggle = useCallback(() => { + if (typeof window === "undefined") return; + if (workspaceTogglePressTimerRef.current !== null) { + window.clearTimeout(workspaceTogglePressTimerRef.current); + } + setWorkspaceTogglePressed(true); + workspaceTogglePressTimerRef.current = window.setTimeout(() => { + workspaceTogglePressTimerRef.current = null; + setWorkspaceTogglePressed(false); + }, 260); + }, []); + + const anchorAppScrollToChat = useCallback(() => { + if (typeof window === "undefined") return; + const el = appRef.current; + if (!el) return; + const pin = () => { + el.scrollLeft = 0; + }; + pin(); + window.requestAnimationFrame(pin); + window.setTimeout(pin, 300); + }, []); + + useEffect(() => { + return () => { + if (sidebarTogglePressTimerRef.current !== null) { + window.clearTimeout(sidebarTogglePressTimerRef.current); + } + if (workspaceTogglePressTimerRef.current !== null) { + window.clearTimeout(workspaceTogglePressTimerRef.current); + } + }; + }, []); + useEffect(() => { let cancelled = false; const override = browserPlatformOverride(); @@ -480,40 +554,39 @@ export default function App() { useEffect(() => { if (typeof window === "undefined" || !window.runtime) return; return window.runtime.EventsOn("app:open-settings", () => { + closeTransientOverlays(); setSettingsTarget("general"); }); - }, []); + }, [closeTransientOverlays]); const [pendingPlanRevision, setPendingPlanRevision] = useState(null); const [footerHeight, setFooterHeight] = useState(0); - const layoutRef = useRef(null); + const footerHeightRef = useRef(0); const footerRef = useRef(null); - const [layoutWidth, setLayoutWidth] = useState(0); - const preferredWorkspacePanelWidth = - rightDockMode === "context" - ? RIGHT_DOCK_CONTEXT_WIDTH - : workspacePreviewActive - ? rightDockPreviewWidth - : rightDockTreeWidth; - const sidebarRenderWidth = sidebarCollapsed ? 0 : sidebarWidth; - const measuredMainWidth = layoutWidth > 0 ? Math.max(0, layoutWidth - sidebarRenderWidth) : CHAT_MIN_WIDTH + WORKSPACE_RESIZER_WIDTH + preferredWorkspacePanelWidth; - const workspacePanelMinWidth = workspacePreviewActive ? RIGHT_DOCK_MIN_WIDTH : RIGHT_DOCK_TREE_MIN_WIDTH; - - const budget = Math.max(0, measuredMainWidth - CHAT_MIN_WIDTH - WORKSPACE_RESIZER_WIDTH); - const workspacePanelFloating = workspacePanelOpen && !workspacePanelMaximized && budget < workspacePanelMinWidth; + const rightDockDetailActive = rightDockMode === "context" ? contextDetailActive : workspacePreviewActive; + const preferredWorkspacePanelWidth = rightDockDetailActive ? rightDockPreviewWidth : rightDockTreeWidth; + const workspacePanelMinWidth = rightDockDetailActive ? RIGHT_DOCK_PREVIEW_MIN_WIDTH : RIGHT_DOCK_TREE_MIN_WIDTH; const resolvedWorkspacePanelWidth = workspacePanelOpen && !workspacePanelMaximized - ? (workspacePanelFloating ? Math.min(measuredMainWidth, Math.max(workspacePanelMinWidth, preferredWorkspacePanelWidth)) : resolveRightDockWidth(measuredMainWidth, preferredWorkspacePanelWidth, workspacePanelMinWidth)) + ? Math.max(workspacePanelMinWidth, preferredWorkspacePanelWidth) : preferredWorkspacePanelWidth; const workspacePanelRenderable = workspacePanelOpen && (workspacePanelMaximized || resolvedWorkspacePanelWidth > 0); - const workspacePanelGridOpen = workspacePanelRenderable && !workspacePanelMaximized && !workspacePanelFloating; + const workspacePanelGridOpen = workspacePanelRenderable && !workspacePanelMaximized; const workspacePanelRenderWidth = workspacePanelMaximized ? preferredWorkspacePanelWidth : resolvedWorkspacePanelWidth; const activeTab = useMemo( () => tabMetas.find((tab) => tab.id === activeTabId) ?? tabMetas.find((tab) => tab.active), [activeTabId, tabMetas], ); const startupSplashHold = state.meta?.ready !== true && !state.meta?.startupErr; - const mode = activeTabId ? modesByTab[activeTabId] ?? "normal" : "normal"; + const legacyMode = activeTabId ? modesByTab[activeTabId] ?? "normal" : "normal"; + const goal = activeTabId ? goalsByTab[activeTabId] ?? state.meta?.goal ?? activeTab?.goal ?? "" : ""; + const collaborationMode = activeTabId + ? collaborationModesByTab[activeTabId] ?? normalizeCollaborationMode(state.meta?.goal ? "goal" : activeTab?.collaborationMode, goal, legacyMode) + : "normal"; + const toolApprovalMode = activeTabId + ? toolApprovalModesByTab[activeTabId] ?? normalizeToolApprovalMode(state.meta?.toolApprovalMode ?? activeTab?.toolApprovalMode, legacyMode, state.meta?.autoApproveTools ?? state.meta?.bypass) + : "ask"; + const controllerReady = state.meta?.ready === true; const setMode = useCallback( (next: Mode | ((prev: Mode) => Mode)) => { if (!activeTabId) return; @@ -527,7 +600,6 @@ export default function App() { [activeTabId], ); const topicbarEditing = Boolean(activeTab?.topicId && activeTab.topicId === renamingTopicId); - const topicbarProjectPrefix = activeTab ? tabWorkspaceTitle(activeTab) : ""; const visibleTabId = activeTabId; const visibleTabs = useMemo(() => { const byId = new Map(tabMetas.map((tab) => [tab.id, tab])); @@ -535,10 +607,14 @@ export default function App() { const missing = tabMetas.filter((tab) => !tabOrderIds.includes(tab.id)); return [...ordered, ...missing].map((tab) => ({ ...tab, - mode: modesByTab[tab.id] ?? normalizeModeValue(tab.mode), + running: tab.id === visibleTabId ? tab.running || state.running : tab.running, + mode: modesByTab[tab.id] ?? normalizeMode(tab.mode), + collaborationMode: collaborationModesByTab[tab.id] ?? normalizeCollaborationMode(tab.collaborationMode, goalsByTab[tab.id] ?? tab.goal, normalizeMode(tab.mode)), + toolApprovalMode: toolApprovalModesByTab[tab.id] ?? normalizeToolApprovalMode(tab.toolApprovalMode, normalizeMode(tab.mode), tab.toolApprovalMode === "yolo"), + goal: goalsByTab[tab.id] ?? tab.goal ?? "", active: tab.id === visibleTabId, })); - }, [modesByTab, tabMetas, tabOrderIds, visibleTabId]); + }, [collaborationModesByTab, goalsByTab, modesByTab, state.running, tabMetas, tabOrderIds, toolApprovalModesByTab, visibleTabId]); useEffect(() => { const ids = tabMetas.map((tab) => tab.id); @@ -557,7 +633,7 @@ export default function App() { let changed = false; const next: Record = {}; for (const tab of tabMetas) { - const mode = normalizeModeValue(tab.mode); + const mode = normalizeMode(tab.mode); next[tab.id] = mode; if (current[tab.id] !== mode) changed = true; } @@ -566,6 +642,45 @@ export default function App() { } return changed ? next : current; }); + setCollaborationModesByTab((current) => { + let changed = false; + const next: Record = {}; + for (const tab of tabMetas) { + const value = normalizeCollaborationMode(tab.collaborationMode, tab.goal, normalizeMode(tab.mode)); + next[tab.id] = value; + if (current[tab.id] !== value) changed = true; + } + for (const id of Object.keys(current)) { + if (!ids.has(id)) changed = true; + } + return changed ? next : current; + }); + setToolApprovalModesByTab((current) => { + let changed = false; + const next: Record = {}; + for (const tab of tabMetas) { + const value = normalizeToolApprovalMode(tab.toolApprovalMode, normalizeMode(tab.mode)); + next[tab.id] = value; + if (current[tab.id] !== value) changed = true; + } + for (const id of Object.keys(current)) { + if (!ids.has(id)) changed = true; + } + return changed ? next : current; + }); + setGoalsByTab((current) => { + let changed = false; + const next: Record = {}; + for (const tab of tabMetas) { + const value = tab.goal ?? ""; + next[tab.id] = value; + if (current[tab.id] !== value) changed = true; + } + for (const id of Object.keys(current)) { + if (!ids.has(id)) changed = true; + } + return changed ? next : current; + }); }, [tabMetas]); useEffect(() => { @@ -576,6 +691,16 @@ export default function App() { setTopicTitleDraft(""); }, [activeTab?.topicId, renamingTopicId]); + useEffect(() => { + if (!activeTabId || !state.meta) return; + const nextGoal = state.meta.goalStatus === "running" ? state.meta.goal ?? "" : ""; + setGoalsByTab((current) => (current[activeTabId] === nextGoal ? current : { ...current, [activeTabId]: nextGoal })); + setCollaborationModesByTab((current) => { + const nextMode = nextGoal ? "goal" : normalizeCollaborationMode(undefined, "", legacyMode); + return current[activeTabId] === nextMode ? current : { ...current, [activeTabId]: nextMode }; + }); + }, [activeTabId, legacyMode, state.meta]); + const syncModeToController = useCallback((m: Mode) => setControllerMode(m), [setControllerMode]); useEffect(() => { @@ -584,18 +709,69 @@ export default function App() { // applyMode is the single source of truth for the input mode: it updates the // local pill and pushes the matching gate state to the controller (plan = read - // only; yolo = auto-approve every tool call). normal clears both. + // only; yolo = auto-approve approval-gated tools while user decisions still wait). + // normal clears both. const applyMode = useCallback( (m: Mode) => { + if (!activeTabId) return; + const nextCollaborationMode: CollaborationMode = modeHasPlan(m) ? "plan" : "normal"; + const nextToolApprovalMode: ToolApprovalMode = modeHasAutoApproveTools(m) ? "yolo" : "ask"; setMode(m); + setCollaborationModesByTab((current) => (current[activeTabId] === nextCollaborationMode ? current : { ...current, [activeTabId]: nextCollaborationMode })); + setToolApprovalModesByTab((current) => (current[activeTabId] === nextToolApprovalMode ? current : { ...current, [activeTabId]: nextToolApprovalMode })); + setGoalsByTab((current) => (current[activeTabId] ? { ...current, [activeTabId]: "" } : current)); void syncModeToController(m); }, - [setMode, syncModeToController], + [activeTabId, setMode, syncModeToController], + ); + const applyCollaborationMode = useCallback( + (m: CollaborationMode) => { + if (!activeTabId) return; + setCollaborationModesByTab((current) => (current[activeTabId] === m ? current : { ...current, [activeTabId]: m })); + if (m === "normal" || m === "plan") { + setGoalsByTab((current) => (current[activeTabId] ? { ...current, [activeTabId]: "" } : current)); + } + setMode(modeFromAxes(m === "plan", toolApprovalMode === "yolo")); + void setControllerCollaborationMode(m); + }, + [activeTabId, setControllerCollaborationMode, setMode, toolApprovalMode], ); - // Shift+Tab cycles auto(normal) → plan → yolo → auto. + const applyToolApprovalMode = useCallback( + (m: ToolApprovalMode) => { + if (!activeTabId) return; + setToolApprovalModesByTab((current) => (current[activeTabId] === m ? current : { ...current, [activeTabId]: m })); + setMode(modeFromAxes(collaborationMode === "plan", m === "yolo")); + void setControllerToolApprovalMode(m); + }, + [activeTabId, collaborationMode, setControllerToolApprovalMode, setMode], + ); + const applyGoal = useCallback( + (nextGoal: string) => { + if (!activeTabId) return; + const trimmed = nextGoal.trim(); + setGoalsByTab((current) => (current[activeTabId] === trimmed ? current : { ...current, [activeTabId]: trimmed })); + setCollaborationModesByTab((current) => { + const nextMode = trimmed ? "goal" : "normal"; + return current[activeTabId] === nextMode ? current : { ...current, [activeTabId]: nextMode }; + }); + setMode(modeFromAxes(false, toolApprovalMode === "yolo")); + void (trimmed ? setControllerGoal(trimmed) : clearControllerGoal()); + }, + [activeTabId, clearControllerGoal, setControllerGoal, setMode, toolApprovalMode], + ); + const startGoal = useCallback( + (nextGoal: string) => { + const trimmed = nextGoal.trim(); + if (!trimmed) return; + applyGoal(trimmed); + send(trimmed, `/goal ${trimmed}`); + }, + [applyGoal, send], + ); + // Shift+Tab toggles only the collaboration axis; tool permission stays independent. const cycleMode = useCallback(() => { - applyMode(mode === "normal" ? "plan" : mode === "plan" ? "yolo" : "normal"); - }, [mode, applyMode]); + applyCollaborationMode(collaborationMode === "plan" ? "normal" : "plan"); + }, [applyCollaborationMode, collaborationMode]); // Switching models rebuilds the controller, which starts in normal mode — so // re-apply the current mode, or the pill would say plan/YOLO while the fresh @@ -603,19 +779,23 @@ export default function App() { const switchModel = useCallback( async (name: string) => { await setModel(name); - await syncModeToController(mode); + await setControllerCollaborationMode(collaborationMode); + await setControllerToolApprovalMode(toolApprovalMode); + if (goal.trim()) await setControllerGoal(goal); }, - [setModel, mode, syncModeToController], + [collaborationMode, goal, setControllerCollaborationMode, setControllerGoal, setControllerToolApprovalMode, setModel, toolApprovalMode], ); // Startup and workspace/model rebuilds create a fresh controller in normal // mode. Re-apply the UI mode once the controller is ready, including the case - // where the user picked YOLO while boot was still loading and SetBypass was a - // harmless no-op. + // where the user picked YOLO while boot was still loading and the legacy + // SetBypass binding was a harmless no-op. useEffect(() => { - if (state.meta?.ready !== true || mode === "normal") return; - void syncModeToController(mode); - }, [state.meta, mode, syncModeToController]); + if (!controllerReady) return; + void setControllerCollaborationMode(collaborationMode); + void setControllerToolApprovalMode(toolApprovalMode); + if (goal.trim()) void setControllerGoal(goal); + }, [collaborationMode, controllerReady, goal, setControllerCollaborationMode, setControllerGoal, setControllerToolApprovalMode, toolApprovalMode]); // The live task list pinned above the composer comes from the most recent // successful top-level todo_write result; failed or still-running attempts do @@ -637,13 +817,10 @@ export default function App() { const [dismissedTodo, setDismissedTodo] = useState(null); const showTodos = shouldShowTodoPanel(todoItem?.id, dismissedTodo, todos); - // useDeferredValue lets React prioritise Composer input (high-priority) over - // Transcript re-renders (low-priority) during streaming. When a keystroke - // and a transcript update collide, the keystroke is processed immediately - // and the transcript re-render is deferred to idle time. - const deferredItems = useDeferredValue(state.items); const sessionTitle = topicTitle(activeTab); const sessionHasContent = state.items.length > 0 || Boolean(state.live?.text || state.live?.reasoning); + const transcriptTurnCount = useMemo(() => state.items.reduce((count, item) => count + (item.kind === "user" ? 1 : 0), 0), [state.items]); + const currentTurnCount = Math.max(transcriptTurnCount, savedTopicTurnCount ?? 0); const getSessionMarkdown = useCallback( () => sessionItemsToMarkdown(sessionTitle, state.items, state.live), [sessionTitle, state.items, state.live], @@ -705,9 +882,26 @@ export default function App() { return; } if (trimmed === "/memory") { + closeTransientOverlays(); setSettingsTarget("memory"); return; } + const goalCommand = /^\/goal(?:\s+(.*))?$/.exec(trimmed); + if (goalCommand) { + const arg = (goalCommand[1] ?? "").trim(); + if (arg && !["status", "clear", "off", "stop", "done"].includes(arg.toLowerCase())) { + applyGoal(arg); + } else if (["clear", "off", "stop", "done"].includes(arg.toLowerCase())) { + applyGoal(""); + } + send(trimmed, submitText.trim()); + return; + } + if (collaborationMode === "goal" && !goal.trim()) { + applyGoal(trimmed); + send(trimmed, `/goal ${submitText.trim()}`); + return; + } const theme = /^\/theme(?:\s+(\S+))?$/.exec(trimmed); if (theme) { const arg = theme[1]?.toLowerCase(); @@ -725,19 +919,21 @@ export default function App() { return; } if (isThemeStyle(arg)) { - const next = themeForStyle(arg); - await app.SetDesktopAppearance(next, arg); - applyTheme(next, arg); - notice(t("settings.themeChanged", { theme: next, style: arg })); + const cur = getTheme(); + await app.SetDesktopAppearance(cur, arg); + applyTheme(cur, arg); + notice(t("settings.themeChanged", { theme: cur, style: arg })); return; } notice(t("settings.themeUnknown", { name: arg }), "warn"); return; } - await syncModeToController(mode); + await setControllerCollaborationMode(collaborationMode); + await setControllerToolApprovalMode(toolApprovalMode); + if (goal.trim()) await setControllerGoal(goal); send(trimmed, submitText.trim()); }, - [switchModel, syncModeToController, mode, send, runShell, notice, t], + [applyGoal, closeTransientOverlays, collaborationMode, goal, send, runShell, notice, setControllerCollaborationMode, setControllerGoal, setControllerToolApprovalMode, switchModel, t, toolApprovalMode], ); const refreshTabMetas = useCallback(async (): Promise => { @@ -759,6 +955,24 @@ export default function App() { }); }, [refreshTabMetas]); + useEffect(() => { + let cancelled = false; + if (!activeTab?.topicId) { + setSavedTopicTurnCount(undefined); + return () => { + cancelled = true; + }; + } + void app.ListProjectTree().then((nodes) => { + if (!cancelled) setSavedTopicTurnCount(activeTopicTurnsFromTree(asArray(nodes), activeTab)); + }).catch(() => { + if (!cancelled) setSavedTopicTurnCount(undefined); + }); + return () => { + cancelled = true; + }; + }, [activeTab?.scope, activeTab?.workspaceRoot, activeTab?.topicId, projectRevision]); + useEffect(() => { let cancelled = false; (async () => { @@ -779,48 +993,47 @@ export default function App() { useEffect(() => { const el = footerRef.current; if (!el || typeof ResizeObserver === "undefined") return; - const update = () => setFooterHeight(Math.round(el.getBoundingClientRect().height)); - update(); - const observer = new ResizeObserver(update); - observer.observe(el); - return () => observer.disconnect(); - }, []); - - useEffect(() => { - const el = layoutRef.current; - if (!el || typeof ResizeObserver === "undefined") return; + let frame = 0; const update = () => { - const width = el.getBoundingClientRect().width; - if (width && Number.isFinite(width)) setLayoutWidth(Math.round(width)); + if (frame) window.cancelAnimationFrame(frame); + frame = window.requestAnimationFrame(() => { + frame = 0; + const next = Math.round(el.getBoundingClientRect().height); + if (Math.abs(footerHeightRef.current - next) < 2) return; + footerHeightRef.current = next; + setFooterHeight(next); + }); }; update(); const observer = new ResizeObserver(update); observer.observe(el); - return () => observer.disconnect(); + return () => { + if (frame) window.cancelAnimationFrame(frame); + observer.disconnect(); + }; }, []); - const startNewSession = useCallback(async () => { - await newSession(); - }, [newSession]); - const toggleSidebar = useCallback(() => { - setSidebarCollapsed((collapsed) => { - const next = !collapsed; - saveSidebarCollapsed(next); - return next; - }); - }, []); + closeTransientOverlays(); + pulseSidebarToggle(); + anchorAppScrollToChat(); + const nextCollapsed = !sidebarCollapsed; + setSidebarCollapsed(nextCollapsed); + saveSidebarCollapsed(nextCollapsed); + }, [anchorAppScrollToChat, closeTransientOverlays, pulseSidebarToggle, sidebarCollapsed]); const setExpandedSidebarWidth = useCallback((width: number) => { + closeTransientOverlays(); const next = clampSidebarWidth(width); setSidebarWidth(next); saveSidebarWidth(next); - }, []); + }, [closeTransientOverlays]); const startSidebarResize = useCallback( (event: ReactPointerEvent) => { if (sidebarCollapsed) return; event.preventDefault(); + closeTransientOverlays(); setSidebarResizing(true); let nextWidth = sidebarWidth; const onMove = (moveEvent: PointerEvent) => { @@ -843,7 +1056,7 @@ export default function App() { window.addEventListener("pointerup", onDone); window.addEventListener("pointercancel", onDone); }, - [sidebarCollapsed, sidebarWidth], + [closeTransientOverlays, sidebarCollapsed, sidebarWidth], ); const resizeSidebarWithKeyboard = useCallback( @@ -865,9 +1078,9 @@ export default function App() { const setSavedWorkspacePanelWidth = useCallback( (width: number) => { - if (rightDockMode === "context") return; - if (workspacePreviewActive) { - const next = clampRightDockWidth(width); + closeTransientOverlays(); + if (rightDockDetailActive) { + const next = clampRightDockPreviewWidth(width); setRightDockPreviewWidth(next); saveRightDockPreviewWidth(next); return; @@ -876,23 +1089,25 @@ export default function App() { setRightDockTreeWidth(next); saveRightDockTreeWidth(next); }, - [rightDockMode, workspacePreviewActive], + [closeTransientOverlays, rightDockDetailActive], ); const ensureWorkspacePanelWidth = useCallback( (width: number) => { + closeTransientOverlays(); if (rightDockMode === "context") return; - const next = clampRightDockWidth(width); + const next = clampRightDockPreviewWidth(width); setRightDockPreviewWidth(next); saveRightDockPreviewWidth(next); }, - [rightDockMode], + [closeTransientOverlays, rightDockMode], ); const startWorkspacePanelResize = useCallback( (event: ReactPointerEvent) => { if (!workspacePanelOpen) return; event.preventDefault(); + closeTransientOverlays(); setWorkspacePanelResizing(true); const startX = event.clientX; const startDockWidth = preferredWorkspacePanelWidth; @@ -900,9 +1115,8 @@ export default function App() { const onMove = (moveEvent: PointerEvent) => { const delta = moveEvent.clientX - startX; nextDockWidth = startDockWidth - delta; - if (rightDockMode === "context") return; - if (workspacePreviewActive) { - setRightDockPreviewWidth(clampRightDockWidth(nextDockWidth)); + if (rightDockDetailActive) { + setRightDockPreviewWidth(clampRightDockPreviewWidth(nextDockWidth)); } else { setRightDockTreeWidth(clampRightDockTreeWidth(nextDockWidth)); } @@ -922,7 +1136,7 @@ export default function App() { window.addEventListener("pointerup", onDone); window.addEventListener("pointercancel", onDone); }, - [preferredWorkspacePanelWidth, rightDockMode, setSavedWorkspacePanelWidth, workspacePanelOpen, workspacePreviewActive], + [closeTransientOverlays, preferredWorkspacePanelWidth, rightDockDetailActive, setSavedWorkspacePanelWidth, workspacePanelOpen], ); const resizeWorkspacePanelWithKeyboard = useCallback( @@ -932,17 +1146,26 @@ export default function App() { setSavedWorkspacePanelWidth(preferredWorkspacePanelWidth + (event.key === "ArrowLeft" ? 16 : -16)); } else if (event.key === "Home") { event.preventDefault(); - setSavedWorkspacePanelWidth(workspacePreviewActive ? RIGHT_DOCK_MIN_WIDTH : RIGHT_DOCK_TREE_MIN_WIDTH); + setSavedWorkspacePanelWidth(rightDockDetailActive ? RIGHT_DOCK_PREVIEW_MIN_WIDTH : RIGHT_DOCK_TREE_MIN_WIDTH); } else if (event.key === "End") { event.preventDefault(); - setSavedWorkspacePanelWidth(workspacePreviewActive ? RIGHT_DOCK_MAX_WIDTH : RIGHT_DOCK_TREE_MAX_WIDTH); + setSavedWorkspacePanelWidth(rightDockDetailActive ? RIGHT_DOCK_MAX_WIDTH : RIGHT_DOCK_TREE_MAX_WIDTH); } }, - [preferredWorkspacePanelWidth, setSavedWorkspacePanelWidth, workspacePreviewActive], + [preferredWorkspacePanelWidth, rightDockDetailActive, setSavedWorkspacePanelWidth], ); const openWorkspacePanel = useCallback( (mode: RightDockMode = rightDockMode) => { + closeTransientOverlays(); + if (mode !== rightDockMode) { + setWorkspacePreviewActive(false); + setContextDetailActive(false); + } else if (mode === "context") { + setWorkspacePreviewActive(false); + } else { + setContextDetailActive(false); + } setRightDockMode(mode); let nextMaximized = workspacePanelMaximized; if (mode === "context") { @@ -961,16 +1184,26 @@ export default function App() { } setWorkspacePanelOpen(true); }, - [rightDockMode, workspacePanelMaximized, workspacePanelOpen], + [closeTransientOverlays, rightDockMode, workspacePanelMaximized, workspacePanelOpen], ); const closeWorkspacePanel = useCallback(() => { + closeTransientOverlays(); if (!workspacePanelOpen) { return; } setWorkspacePanelMaximized(false); setWorkspacePanelOpen(false); - }, [workspacePanelOpen]); + }, [closeTransientOverlays, workspacePanelOpen]); + + const toggleWorkspacePanel = useCallback(() => { + pulseWorkspaceToggle(); + if (workspacePanelRenderable) { + closeWorkspacePanel(); + return; + } + openWorkspacePanel("context"); + }, [closeWorkspacePanel, openWorkspacePanel, pulseWorkspaceToggle, workspacePanelRenderable]); const openRightDockMode = useCallback( (mode: RightDockMode) => { @@ -979,11 +1212,30 @@ export default function App() { [openWorkspacePanel], ); + const handleWorkspacePreviewModeChange = useCallback( + (active: boolean) => { + if (workspacePreviewActive === active) return; + closeTransientOverlays(); + setWorkspacePreviewActive(active); + }, + [closeTransientOverlays, workspacePreviewActive], + ); + + const handleContextDetailModeChange = useCallback( + (active: boolean) => { + if (contextDetailActive === active) return; + closeTransientOverlays(); + setContextDetailActive(active); + }, + [closeTransientOverlays, contextDetailActive], + ); + const layoutStyle = useMemo( () => ({ "--sidebar-expanded-width": `${sidebarWidth}px`, "--chat-min-width": `${CHAT_MIN_WIDTH}px`, + "--chat-docked-min-width": `${CHAT_DOCKED_MIN_WIDTH}px`, "--workspace-width": `${workspacePanelRenderWidth}px`, "--workspace-resizer-width": `${WORKSPACE_RESIZER_WIDTH}px`, }) as CSSProperties, @@ -1003,12 +1255,14 @@ export default function App() { }, []); const handleTabChange = useCallback(async (id: string) => { + closeTransientOverlays(); await switchTab(id); await refreshTabMetas(); setTabRevealSignal((signal) => signal + 1); - }, [refreshTabMetas, switchTab]); + }, [closeTransientOverlays, refreshTabMetas, switchTab]); const handleTabClose = useCallback(async (id: string) => { + closeTransientOverlays(); setModesByTab((current) => { if (!(id in current)) return current; const next = { ...current }; @@ -1029,9 +1283,10 @@ export default function App() { await closeTab(id); await refreshTabMetas(); setTabRevealSignal((signal) => signal + 1); - }, [activeTabId, closeTab, refreshTabMetas]); + }, [activeTabId, closeTab, closeTransientOverlays, refreshTabMetas]); const handleTabsClose = useCallback(async (ids: string[], nextActiveTabId?: string) => { + closeTransientOverlays(); const currentIds = tabMetas.map((tab) => tab.id); const targets = ids.filter((id, index) => currentIds.includes(id) && ids.indexOf(id) === index); if (targets.length === 0) return; @@ -1043,7 +1298,7 @@ export default function App() { } await refreshTabMetas(); setTabRevealSignal((signal) => signal + 1); - }, [closeTab, refreshTabMetas, switchTab, tabMetas]); + }, [closeTab, closeTransientOverlays, refreshTabMetas, switchTab, tabMetas]); const handleTabsReorder = useCallback(async (ids: string[]) => { setTabOrderIds(ids); @@ -1058,9 +1313,10 @@ export default function App() { }, [refreshTabMetas, reorderTabs]); const handleNewTab = useCallback(async () => { - const activeWorkspaceRoot = activeTab?.workspaceRoot || state.meta?.cwd || ""; - const targetScope = activeTab?.scope === "global" || !activeWorkspaceRoot ? "global" : "project"; - const workspaceRoot = targetScope === "project" ? activeWorkspaceRoot : ""; + closeTransientOverlays(); + const activeWorkspaceRoot = activeTab?.scope === "project" ? activeTab.workspaceRoot || "" : ""; + const targetScope = activeWorkspaceRoot ? "project" : "global"; + const workspaceRoot = activeWorkspaceRoot; const topic = await app.CreateTopic(targetScope, workspaceRoot, ""); if (targetScope === "global" || !workspaceRoot) { await openGlobalTab(topic.id); @@ -1070,18 +1326,7 @@ export default function App() { setProjectRevision((value) => value + 1); await refreshTabMetas(); setTabRevealSignal((signal) => signal + 1); - }, [activeTab?.scope, activeTab?.workspaceRoot, openGlobalTab, openProjectTab, refreshTabMetas, state.meta?.cwd]); - - // ── Command palette (⌘K) ──────────────────────────────────────────── - useEffect(() => { - const onKeyDown = (event: globalThis.KeyboardEvent) => { - if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey || event.key.toLowerCase() !== "k") return; - event.preventDefault(); - setPaletteOpen((open) => !open); - }; - window.addEventListener("keydown", onKeyDown, { capture: true }); - return () => window.removeEventListener("keydown", onKeyDown, { capture: true }); - }, []); + }, [activeTab?.scope, activeTab?.workspaceRoot, closeTransientOverlays, openGlobalTab, openProjectTab, refreshTabMetas]); const handleMessageAction = useCallback(async (turn: number, scope: string) => { await rewind(turn, scope); @@ -1098,6 +1343,7 @@ export default function App() { }, [refreshTabMetas, rewind]); const handleOpenTopic = useCallback(async (scope: string, workspaceRoot: string, topicId: string) => { + closeTransientOverlays(); if (scope === "global") { await openGlobalTab(topicId); } else { @@ -1105,70 +1351,28 @@ export default function App() { } await refreshTabMetas(); setTabRevealSignal((signal) => signal + 1); - }, [openGlobalTab, openProjectTab, refreshTabMetas]); + }, [closeTransientOverlays, openGlobalTab, openProjectTab, refreshTabMetas]); // History drawer: project menus can open a scoped saved-session list. Idle row // clicks resume; running row clicks only preview through PreviewSession. const openProjectHistory = useCallback(async (scope: "global" | "project", workspaceRoot: string) => { + closeTransientOverlays(); const filter = { scope, workspaceRoot }; setHistView({ kind: "history", source: "scope", filter, sessions: sessionsForScope(await listSessions(), filter) }); - }, [listSessions]); + }, [closeTransientOverlays, listSessions]); const openAllHistory = useCallback(async () => { + closeTransientOverlays(); setHistView({ kind: "history", source: "all", sessions: await listSessions() }); - }, [listSessions]); + }, [closeTransientOverlays, listSessions]); const openTrash = useCallback(async () => { + closeTransientOverlays(); setHistView({ kind: "trash", sessions: await listTrashedSessions() }); - }, [listTrashedSessions]); - const closeHistory = useCallback(() => setHistView(null), []); - - // ── Command palette (⌘K) item builder ─────────────────────────────── - const buildPaletteItems = useCallback((): PaletteItem[] => { - const items: PaletteItem[] = []; - - for (const tab of tabMetas) { - items.push({ - id: `tab:${tab.id}`, - title: tab.topicTitle || "Untitled", - hint: tab.scope === "global" ? "Global" : tab.workspaceName || tab.workspaceRoot, - group: t("sidebar.conversations"), - keywords: [tab.label], - run: () => void handleTabChange(tab.id), - }); - } + }, [closeTransientOverlays, listTrashedSessions]); + const closeHistory = useCallback(() => { + closeTransientOverlays(); + setHistView(null); + }, [closeTransientOverlays]); - items.push( - { - id: "action:new-session", - title: t("topbar.newSession"), - group: "Actions", - keywords: ["new", "chat", "session"], - run: () => void handleNewTab(), - }, - { - id: "action:history", - title: t("sidebar.allHistory"), - group: "Actions", - keywords: ["history", "sessions", "past"], - run: () => void openAllHistory(), - }, - { - id: "action:settings", - title: t("topbar.settings"), - group: "Actions", - keywords: ["preferences", "config", "options"], - run: () => setSettingsTarget("general"), - }, - { - id: "action:trash", - title: t("sidebar.trash"), - group: "Actions", - keywords: ["deleted", "bin"], - run: () => void openTrash(), - }, - ); - - return items; - }, [tabMetas, handleTabChange, handleNewTab, openAllHistory, openTrash, t]); const onResumeSession = useCallback( async (session: SessionMeta) => { if (state.running) return; @@ -1200,6 +1404,49 @@ export default function App() { }, [openGlobalTab, openProjectTab, refreshTabMetas, state.running, resumeSession, t, showToast], ); + + // Command palette: ⌘K / Ctrl+K opens a fuzzy navigator over commands and + // recent sessions. Sessions are snapshotted on open so the list is stable + // while the palette is up. + const openPalette = useCallback(async () => { + closeTransientOverlays(); + setPaletteOpen(true); + setPaletteSessions(await listSessions().catch(() => [])); + }, [closeTransientOverlays, listSessions]); + useEffect(() => { + const onKey = (e: globalThis.KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setPaletteOpen((cur) => { + if (!cur) void openPalette(); + return cur; + }); + } else if (e.key === "Escape") { + setPaletteOpen(false); + } + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [openPalette]); + const paletteItems = useMemo(() => { + const cmds: PaletteItem[] = [ + { id: "cmd-new", group: t("palette.group.commands"), title: t("palette.cmd.newSession"), keywords: ["new", "新建"], run: () => void handleNewTab() }, + { id: "cmd-history", group: t("palette.group.commands"), title: t("palette.cmd.history"), keywords: ["history", "历史"], run: () => void openAllHistory() }, + { id: "cmd-trash", group: t("palette.group.commands"), title: t("palette.cmd.trash"), keywords: ["trash", "回收站"], run: () => void openTrash() }, + { id: "cmd-settings", group: t("palette.group.commands"), title: t("palette.cmd.settings"), keywords: ["settings", "设置"], run: () => setSettingsTarget("general") }, + { id: "cmd-appearance", group: t("palette.group.commands"), title: t("palette.cmd.appearance"), keywords: ["theme", "appearance", "外观", "主题"], run: () => setSettingsTarget("appearance") }, + { id: "cmd-memory", group: t("palette.group.commands"), title: t("palette.cmd.memory"), keywords: ["memory", "记忆"], run: () => setSettingsTarget("memory") }, + { id: "cmd-models", group: t("palette.group.commands"), title: t("palette.cmd.models"), keywords: ["model", "模型"], run: () => setSettingsTarget("models") }, + ]; + const sessionItems: PaletteItem[] = paletteSessions.slice(0, 12).map((s) => ({ + id: `sess-${s.path}`, + group: t("palette.group.sessions"), + title: s.title?.trim() || s.preview || t("history.emptySession"), + hint: s.workspaceRoot || undefined, + run: () => void onResumeSession(s), + })); + return [...cmds, ...sessionItems]; + }, [t, paletteSessions, handleNewTab, openAllHistory, openTrash, onResumeSession]); // Delete / rename act on disk, then re-fetch so the panel reflects the change. const onDeleteSession = useCallback( async (path: string) => { @@ -1270,12 +1517,6 @@ export default function App() { return picked; }, [pickWorkspace, switchWorkspace, refreshTabMetas]); - const removeWorkspace = useCallback(async (path: string) => { - await app.RemoveWorkspace(path); - setProjectRevision((value) => value + 1); - await refreshTabMetas(); - }, [refreshTabMetas]); - const refreshProjectsAndTabs = useCallback(async () => { setProjectRevision((value) => value + 1); const tabs = await refreshTabMetas(); @@ -1332,26 +1573,28 @@ export default function App() { ? t("sidebar.expand") : t("sidebar.collapse"); const sidebarNavTooltipDisabled = !sidebarCollapsed; - const workspacePanelResetWidth = rightDockMode === "context" - ? RIGHT_DOCK_CONTEXT_WIDTH - : workspacePreviewActive + const browserPreviewChrome = typeof window !== "undefined" && !window.runtime; + const workspacePanelResetWidth = rightDockDetailActive ? RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH : defaultRightDockTreeWidth(); - const workspacePanelMaxWidth = workspacePreviewActive ? RIGHT_DOCK_MAX_WIDTH : RIGHT_DOCK_TREE_MAX_WIDTH; + const workspacePanelMaxWidth = rightDockDetailActive ? RIGHT_DOCK_MAX_WIDTH : RIGHT_DOCK_TREE_MAX_WIDTH; + const topicbarTitle = topicDisplayTitle(activeTab); + const topicbarWorkspaceLabel = activeTab ? tabWorkspaceTitle(activeTab) : ""; + const topicbarWorkspacePath = activeTab?.scope === "project" ? activeTab.workspaceRoot || state.meta?.cwd : ""; + const topicbarSubtitleVisible = Boolean(topicbarWorkspaceLabel); + const topicbarSubtitleTitle = topicbarWorkspacePath || topicbarWorkspaceLabel; return ( -
+
-
+ void handleTabChange(id)} + onTabClose={(id) => void handleTabClose(id)} + onTabsClose={(ids, nextActiveTabId) => void handleTabsClose(ids, nextActiveTabId)} + onTabsReorder={(ids) => void handleTabsReorder(ids)} + onNewTab={() => void handleNewTab()} + onOpenPalette={() => void openPalette()} + /> + +
- -
) : ( - + )} @@ -1605,7 +1847,7 @@ export default function App() { onAnswer={(allow, session, persist, scope) => { // Approving an exit_plan_mode plan leaves plan mode; sync the // tab-local indicator and persisted safe mode immediately. - if (state.approval!.tool === "exit_plan_mode" && allow) applyMode("normal"); + if (state.approval!.tool === "exit_plan_mode" && allow) applyCollaborationMode("normal"); approve(state.approval!.id, allow, session, persist, scope); }} onRevisePlan={(text) => { @@ -1613,7 +1855,7 @@ export default function App() { approve(state.approval!.id, false, false, false); }} onExitPlan={() => { - applyMode("normal"); + applyCollaborationMode("normal"); approve(state.approval!.id, false, false, false); }} /> @@ -1625,9 +1867,11 @@ export default function App() { onDismiss={() => answerQuestion(state.ask!.id, [])} /> )} - applyGoal("")} onSwitchModel={switchModel} onSetEffort={setEffort} - onPickFolder={switchFolder} - onRemoveWorkspace={removeWorkspace} insertRequest={composerInsertRequest} - disabled={state.meta?.ready === false || state.messageAction != null || state.approval != null || state.ask != null} - decisionPending={state.messageAction != null || state.approval != null || state.ask != null} + disabled={state.meta?.ready === false || state.messageAction != null || state.approval != null || state.ask != null} + decisionPending={state.messageAction != null || state.approval != null || state.ask != null} ready={state.meta?.ready === true} turnStartAt={state.turnStartAt} turnTokens={state.turnTokens} retry={state.retry} - workspaceRefreshSignal={projectRevision} + transientDismissSignal={transientOverlayDismissSignal} /> @@ -1687,6 +1936,12 @@ export default function App() { ].join(" ")} aria-label={t("rightDock.workbench")} > +
+
+

{t("workspace.title")}

+
+ +
{SHOW_CONTEXT_DOCK && ( @@ -1697,7 +1952,7 @@ export default function App() { className={`workbench-dock__tab${rightDockMode === "context" ? " workbench-dock__tab--active" : ""}`} onClick={() => openRightDockMode("context")} > - + {t("rightDock.overview")} )} @@ -1733,6 +1988,7 @@ export default function App() { sessionCurrency={state.sessionCurrency} scopeLabel={topicScopeLabel(activeTab)} refreshKey={dockRefreshKey} + onDetailModeChange={handleContextDetailModeChange} /> ) : ( setWorkspacePanel(false)} - onToggleMaximized={() => setWorkspacePanelMaximized((value) => !value)} - onPreviewModeChange={setWorkspacePreviewActive} + onToggleMaximized={() => { + closeTransientOverlays(); + setWorkspacePanelMaximized((value) => !value); + }} + onPreviewModeChange={handleWorkspacePreviewModeChange} onAddToChat={addWorkspaceTextToComposer} onRequestPanelWidth={ensureWorkspacePanelWidth} refreshKey={dockRefreshKey} @@ -1779,19 +2038,19 @@ export default function App() { /> )} + setPaletteOpen(false)} + items={paletteItems} + placeholder={t("palette.placeholder")} + emptyText={t("palette.empty")} + /> + {startupSplashVisible && ( setStartupSplashVisible(false)} /> )} {needsOnboarding && setNeedsOnboarding(false)} />} - - setPaletteOpen(false)} - items={buildPaletteItems()} - placeholder="Quick actions…" - emptyText="No matches" - />
); diff --git a/desktop/frontend/src/components/AnchoredPopover.tsx b/desktop/frontend/src/components/AnchoredPopover.tsx index dd85aef1a..9474748d7 100644 --- a/desktop/frontend/src/components/AnchoredPopover.tsx +++ b/desktop/frontend/src/components/AnchoredPopover.tsx @@ -6,9 +6,11 @@ type PopoverPosition = { left: number; top: number; }; +type PopoverPhase = "closed" | "open" | "closing"; const EDGE_GAP = 8; const DEFAULT_OFFSET = 8; +export const ANCHORED_POPOVER_CLOSE_MS = 140; function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); @@ -49,6 +51,7 @@ export function AnchoredPopover({ offset = DEFAULT_OFFSET, placement = "auto", style, + closing = false, }: { open: boolean; anchorRef: RefObject; @@ -59,61 +62,100 @@ export function AnchoredPopover({ offset?: number; placement?: "auto" | "bottom"; style?: CSSProperties; + closing?: boolean; }) { + const [phase, setPhase] = useState(open ? "open" : "closed"); const [position, setPosition] = useState(null); const popoverRef = useRef(null); + const phaseRef = useRef(phase); useLayoutEffect(() => { - if (!open) { + let id: number | undefined; + if (open) { + phaseRef.current = "open"; + setPhase("open"); + return undefined; + } + if (phaseRef.current === "closed") return undefined; + phaseRef.current = "closing"; + setPhase("closing"); + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + id = window.setTimeout(() => { + phaseRef.current = "closed"; + setPhase("closed"); + setPosition(null); + }, reduceMotion ? 0 : ANCHORED_POPOVER_CLOSE_MS); + return () => { + if (id !== undefined) window.clearTimeout(id); + }; + }, [open]); + + const rendered = closing || phase !== "closed"; + + useLayoutEffect(() => { + if (!rendered) { setPosition(null); return; } - const anchor = anchorRef.current?.getBoundingClientRect(); - const menu = document.querySelector("[data-anchored-popover='active']")?.getBoundingClientRect(); - if (!anchor || !menu) return; - const next = calculatePosition(anchor, menu, align, offset, placement); - setPosition((current) => (samePosition(current, next) ? current : next)); - }, [open, align, offset, placement]); + const updatePosition = () => { + const anchor = anchorRef.current?.getBoundingClientRect(); + const menu = popoverRef.current?.getBoundingClientRect(); + if (!anchor || !menu) return; + const next = calculatePosition(anchor, menu, align, offset, placement); + setPosition((current) => (samePosition(current, next) ? current : next)); + }; + updatePosition(); + const frame = window.requestAnimationFrame(updatePosition); + return () => window.cancelAnimationFrame(frame); + }, [rendered, anchorRef, align, offset, placement]); useEffect(() => { - if (!open) return; + if (!open || closing) return; const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); }; + const closeOnOutsideClick = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (popoverRef.current?.contains(target) || anchorRef.current?.contains(target)) return; + onClose(); + }; const closeOnViewportChange = () => onClose(); window.addEventListener("keydown", closeOnEscape); + document.addEventListener("click", closeOnOutsideClick); window.addEventListener("resize", closeOnViewportChange); return () => { window.removeEventListener("keydown", closeOnEscape); + document.removeEventListener("click", closeOnOutsideClick); window.removeEventListener("resize", closeOnViewportChange); }; - }, [onClose, open]); + }, [anchorRef, onClose, open]); - if (!open) return null; + if (!rendered) return null; return createPortal( - <> -
-
{ - event.stopPropagation(); - }} - onClick={(event) => { - event.stopPropagation(); - }} - > - {children} -
- , +
{ + event.stopPropagation(); + }} + onClick={(event) => { + event.stopPropagation(); + }} + > + {children} +
, document.body, ); } diff --git a/desktop/frontend/src/components/AppChrome.tsx b/desktop/frontend/src/components/AppChrome.tsx new file mode 100644 index 000000000..d647d44fb --- /dev/null +++ b/desktop/frontend/src/components/AppChrome.tsx @@ -0,0 +1,174 @@ +import { Minus, PanelLeft, PanelRight, Search, Square, X } from "lucide-react"; +import { TabBar } from "./TabBar"; +import type { TabMeta } from "../lib/types"; +import { useT } from "../lib/i18n"; + +type DesktopPlatform = "darwin" | "windows" | "linux"; + +interface AppChromeProps { + platform: DesktopPlatform; + browserPreviewChrome: boolean; + tabs: TabMeta[]; + activeTabId?: string; + revealActiveSignal: number; + commandCompact: boolean; + sidebarTogglePressed: boolean; + sidebarExpandBlocked: boolean; + sidebarCollapsed: boolean; + sidebarToggleTitle: string; + workspacePanelMaximized: boolean; + workspacePanelRenderable: boolean; + workspaceTogglePressed: boolean; + workspacePanelLabel: string; + onToggleSidebar: () => void; + onToggleWorkspacePanel: () => void; + onTabChange: (tabId: string) => void; + onTabClose: (tabId: string) => void; + onTabsClose: (tabIds: string[], nextActiveTabId?: string) => void; + onTabsReorder: (tabIds: string[]) => void; + onNewTab: () => void; + onOpenPalette: () => void; +} + +export function AppChrome({ + platform, + browserPreviewChrome, + tabs, + activeTabId, + revealActiveSignal, + commandCompact, + sidebarTogglePressed, + sidebarExpandBlocked, + sidebarCollapsed, + sidebarToggleTitle, + workspacePanelMaximized, + workspacePanelRenderable, + workspaceTogglePressed, + workspacePanelLabel, + onToggleSidebar, + onToggleWorkspacePanel, + onTabChange, + onTabClose, + onTabsClose, + onTabsReorder, + onNewTab, + onOpenPalette, +}: AppChromeProps) { + const t = useT(); + const darwinChrome = platform === "darwin"; + const detachCommand = !darwinChrome; + const showWindowsPreviewControls = browserPreviewChrome && platform === "windows"; + const chromeClassName = [ + "app-chrome", + "app-chrome--tabs", + darwinChrome ? "app-chrome--darwin-tabs" : "app-chrome--native-tabs", + !darwinChrome ? "app-chrome--identityless" : "", + showWindowsPreviewControls ? "app-chrome--preview-window-controls" : "", + `app-chrome--platform-${platform}`, + ].filter(Boolean).join(" "); + + const tabBar = ( + + ); + + return ( +
+ {browserPreviewChrome && darwinChrome && ( + + )} + + + {darwinChrome ? ( +
+ {tabBar} +
+ ) : ( + <> +
+ {tabBar} +
+ {detachCommand && ( +
+ +
+ )} + + )} + + {!workspacePanelMaximized && ( + + )} + {showWindowsPreviewControls && ( + + )} +
+ ); +} diff --git a/desktop/frontend/src/components/CapabilitiesPanel.tsx b/desktop/frontend/src/components/CapabilitiesPanel.tsx index 3ae7665c2..d886e2177 100644 --- a/desktop/frontend/src/components/CapabilitiesPanel.tsx +++ b/desktop/frontend/src/components/CapabilitiesPanel.tsx @@ -6,6 +6,7 @@ import type { CapabilitiesView, MCPServerInput, ServerView, SkillRootSkillView, import { InlineConfirmButton } from "./InlineConfirmButton"; import { ResizableDrawer } from "./ResizableDrawer"; import { Tooltip } from "./Tooltip"; +import { ModalCloseButton } from "./ModalCloseButton"; // CapabilitiesPanel is the desktop MCP & Skills drawer — the GUI counterpart to // the CLI's /mcp + /skill, aligning with Claude Code's Customize → Connectors: @@ -137,16 +138,14 @@ export function CapabilitiesPanel({
{t("caps.title")}
{view &&
{summary}
}
- - - - - - +
+ + + + +
{!view ? ( diff --git a/desktop/frontend/src/components/CommandPalette.tsx b/desktop/frontend/src/components/CommandPalette.tsx index 98743fbb3..03b20667b 100644 --- a/desktop/frontend/src/components/CommandPalette.tsx +++ b/desktop/frontend/src/components/CommandPalette.tsx @@ -1,4 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { Command, Search } from "lucide-react"; +import { useMountTransition } from "../lib/useMountTransition"; // CommandPalette is a ⌘K / Ctrl+K modal that surfaces the desktop app's // long-tail navigation surface. Tabs through sessions, slash-commands, and @@ -49,6 +51,9 @@ export function CommandPalette({ const [query, setQuery] = useState(""); const [active, setActive] = useState(0); const inputRef = useRef(null); + // Keep the palette mounted through its exit animation after `open` flips + // false; `status` drives the enter/exit keyframes via data-state. + const { mounted, status } = useMountTransition(open, 200); // Re-init whenever the palette opens: clear the query, reset the // highlight, and steal focus. Doing it on the open edge (not on every @@ -158,16 +163,22 @@ export function CommandPalette({ return () => document.removeEventListener("keydown", onKey); }, [open, flat, active, onClose]); - if (!open) return null; + if (!mounted) return null; // The running counter maps a flat-index back to its group header so we // can render the section dividers in order. let running = 0; return ( -
-
e.stopPropagation()} role="dialog" aria-modal="true" aria-label={placeholder}> +
+
e.stopPropagation()} role="dialog" aria-modal="true" aria-label={placeholder}>
+