From b1bca07c4713ca634d80890dfe664b0d211e3f61 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 24 Aug 2026 09:39:28 +0700 Subject: [PATCH] feat(sagi): spawn a real claude session for Sagi's Ask handoff Unmatched Ask questions now adopt a dedicated "Sagi" lane (POST /api/lanes/ensure, cwd defaults to $HOME) and spawn a tmux-backed claude run there instead of just prefilling the Run page composer. --- ARCHITECTURE.md | 15 +++++++--- client/src/components/Sagi/Sagi.tsx | 31 ++++++++++++++++---- client/src/components/Sagi/useSagiBrain.ts | 34 ++++++++++++++++++---- client/src/lib/api.ts | 6 ++-- docs/API.md | 17 +++++++---- server/routes/lanes.js | 22 +++++++++++--- 6 files changed, 99 insertions(+), 26 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 402b4911..bfd8d0d7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2138,7 +2138,9 @@ The detection layer carries all of the signal value: the dashboard tells the use ## Sagi Companion Subsystem -Sagi is a **client-only** floating mascot companion that reacts to live session activity. It is purely additive UI: there is **no server/backend code**, **no new API routes**, **no new WebSocket message types**, and **no database changes**. Sagi reuses the existing real-time event stream (the same `eventBus` every page already consumes) and the existing **Run** page for its "ask a real question" path. The entire subsystem lives under `client/src/components/Sagi/`. +Sagi is a **client-only** floating mascot companion that reacts to live session activity. It is purely additive UI: there are **no new server routes**, **no new WebSocket message types**, and **no database schema changes**. Sagi reuses the existing real-time event stream (the same `eventBus` every page already consumes) and the existing **Run** lane/run APIs for its "ask a real question" path. The entire subsystem lives under `client/src/components/Sagi/`. + +The only server-side accommodation is that `POST /api/lanes/ensure` now defaults a missing `cwd` to the user's home directory, so the mascot can adopt a stable "Sagi" lane (titled, fixed cwd) without forcing the client to know any absolute path. The behavior is gated by the ensure route; the underlying `createLane` still rejects empty cwds. The design follows a strict **pure-core / hook / presentational** split: a framework-free brain (a `WSMessage` reducer plus a mood state machine with an injected clock and zero side effects) is fully unit-tested in isolation, a single React hook is the only consumer of the global `eventBus` and the only owner of timers and side effects, and the SVG/markup components are pure presentational views driven by props. @@ -2192,9 +2194,13 @@ flowchart LR DERIVED --> AVATAR["SagiAvatar"] DERIVED --> BUBBLE["SpeechBubble"] DERIVED --> PANEL["SagiPanel"] - PANEL -->|"unmatched Ask"| RUN["/run?prompt=…
(existing Run page)"] + PANEL -->|"unmatched Ask"| ENSURE["POST /api/lanes/ensure
(adopt 'Sagi' lane, cwd = home)"] + ENSURE --> START["POST /api/run
(spawn tmux-backed claude
in Sagi lane, initialPrompt=…)"] + START --> RUN["/run?laneId=…
(existing Run page picks up
the live run)"] style PUB fill:#f59e0b,stroke:#fbbf24,color:#000 + style ENSURE fill:#6366f1,stroke:#818cf8,color:#fff + style START fill:#6366f1,stroke:#818cf8,color:#fff style RUN fill:#10b981,stroke:#34d399,color:#fff ``` @@ -2210,7 +2216,7 @@ The mood state machine in `deriveMood` resolves to a single expression using a f | **`SpeechBubble.tsx`** | Pure presentational speech bubble. | | **`SagiPanel.tsx`** | Pure presentational panel: status readout + quick actions + the Ask box. | | **`Sagi.tsx`** | Shell component. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. | -| **`intents.ts`** | Pure local Q&A over the cached status snapshot. Queries that don't match a local intent become a handoff to the existing Run page via `/run?prompt=…`. | +| **`intents.ts`** | Pure local Q&A over the cached status snapshot. Queries that don't match a local intent become a handoff: `Sagi.tsx` calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/run` to spawn a fresh tmux-backed `claude` session in that lane with the prompt as its `initialPrompt`, then navigates to `/run?laneId=…&autostart=1` so the live run is observable. | | **`quips.ts`** | Pure mood → phrase pools. | | **`prefs.ts`** | `localStorage`-backed enabled / muted preferences with cross-tab sync. | | **`sagi.css`** | Keyframes and per-mood expressions; selected via the `data-mood` attribute. | @@ -2223,7 +2229,8 @@ Sagi's only contact with the rest of the app is four light, additive touchpoints | --- | --- | | `client/src/components/Layout.tsx` | Mounts `` once, as a sibling of ``. | | `client/src/pages/Settings.tsx` | On/off toggle wired to `sagiPrefs` (`localStorage`). | -| `client/src/pages/Workspace.tsx` | Reads `?prompt=` to prefill the prompt box for Sagi's Ask handoff. | +| `client/src/pages/Run.tsx` | Consumes `?laneId=` to attach the page to the lane whose run Sagi just spawned. | +| `server/routes/lanes.js` (`POST /api/lanes/ensure`) | Accepts a missing `cwd` and resolves it to `os.homedir()` so the mascot can adopt a stable "Sagi" lane. | | `client/src/i18n/locales/{en,vi}/settings.json` | `sagi.*` strings for the Settings toggle (zh / ko fall back to the hardcoded English defaults). | --- diff --git a/client/src/components/Sagi/Sagi.tsx b/client/src/components/Sagi/Sagi.tsx index ab9b0424..88326d22 100644 --- a/client/src/components/Sagi/Sagi.tsx +++ b/client/src/components/Sagi/Sagi.tsx @@ -78,6 +78,7 @@ import { useSagiBrain } from "./useSagiBrain"; import { useSagiPosition, SAGI_SIZE } from "./useSagiPosition"; import { matchIntent } from "./intents"; import { sagiPrefs } from "./prefs"; +import { api } from "../../lib/api"; import "./sagi.css"; const FLYOUT_GAP = 10; // px between avatar and flyout @@ -196,11 +197,31 @@ export function Sagi() { (query: string): string | null => { const result = matchIntent(query, brain.status); if (result.kind === "answer") return result.text; - // Handoff: spawn a real claude via the existing Run page. `autostart=1` - // tells Run to fire the prompt automatically once it's prefilled, so the - // question is actually sent instead of just dropped into the composer. - navigate(`/run?prompt=${encodeURIComponent(result.prompt)}&autostart=1`); - setOpen(false); + // Handoff: ensure the "Sagi" lane exists for this dashboard, spawn a + // fresh claude session in it carrying the user's prompt as its first + // message, then jump to the Run page so the live pane is observable. + // Fire-and-forget — the panel already clears its composer on null and + // closes; errors surface as an answer instead of silently swallowing. + const prompt = result.prompt; + brain.setThinking(true); + (async () => { + try { + const { lane } = await api.lanes.ensure({ title: "Sagi" }); + await api.run.start({ + laneId: lane.id, + cwd: lane.cwd, + initialPrompt: prompt, + permissionMode: "acceptEdits", + }); + navigate(`/run?laneId=${lane.id}&autostart=1`); + setOpen(false); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + brain.setBubble(`couldn't start that session: ${msg}`); + } finally { + brain.setThinking(false); + } + })(); return null; }, [brain, navigate] diff --git a/client/src/components/Sagi/useSagiBrain.ts b/client/src/components/Sagi/useSagiBrain.ts index 12a57c70..f55e788c 100644 --- a/client/src/components/Sagi/useSagiBrain.ts +++ b/client/src/components/Sagi/useSagiBrain.ts @@ -87,6 +87,10 @@ export interface SagiBrain { status: SagiStatus; bubble: string | null; dismissBubble: () => void; + /** Imperative bubble setter for one-off messages from the shell (e.g. spawn + * errors). Bypasses the throttle because the caller decided this message + * must be seen right now. Auto-clears after the standard bubble TTL. */ + setBubble: (text: string) => void; muted: boolean; toggleMute: () => void; clearAlerts: () => void; @@ -103,7 +107,7 @@ export function useSagiBrain(): SagiBrain { connected: true, })); const [tick, setTick] = useState(now0); - const [bubble, setBubble] = useState(null); + const [bubble, setBubbleState] = useState(null); const [muted, setMuted] = useState(() => sagiPrefs.getMuted()); const bubbleTimer = useRef>(); @@ -121,8 +125,8 @@ export function useSagiBrain(): SagiBrain { if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return; lastBubbleAt.current = t; clearTimeout(bubbleTimer.current); - setBubble(text); - bubbleTimer.current = setTimeout(() => setBubble(null), BUBBLE_MS); + setBubbleState(text); + bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS); }, []); // Seed from the REST snapshot on mount so counts are accurate immediately - @@ -176,7 +180,7 @@ export function useSagiBrain(): SagiBrain { const dismissBubble = useCallback(() => { clearTimeout(bubbleTimer.current); - setBubble(null); + setBubbleState(null); }, []); const toggleMute = useCallback(() => { @@ -193,5 +197,25 @@ export function useSagiBrain(): SagiBrain { [] ); - return { mood, status, bubble, dismissBubble, muted, toggleMute, clearAlerts, setThinking }; + // One-shot bubble: clears itself after BUBBLE_MS. Used by the shell for + // imperative messages (e.g. spawn errors) that must appear now and fade, + // bypassing the throttled mood-driven showBubble path. + const setBubble = useCallback((text: string) => { + if (!text) return; + clearTimeout(bubbleTimer.current); + setBubbleState(text); + bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS); + }, []); + + return { + mood, + status, + bubble, + dismissBubble, + setBubble, + muted, + toggleMute, + clearAlerts, + setThinking, + }; } diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 70c7dc2a..fe588f5f 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1832,10 +1832,12 @@ export const api = { list: () => request<{ lanes: Lane[]; counts: LaneCounts }>("/lanes"), /** * POST /api/lanes/ensure — ensure a lane exists for the given cwd. - * @param body The cwd and optional title. + * @param body The cwd and optional title. When `cwd` is omitted the server + * falls back to the user's home directory — useful for lanes whose + * location isn't tied to a specific project (e.g. Sagi's mascot lane). * @returns `{ lane, created }` — the lane (newly created or existing) and whether it was created. */ - ensure: (body: { cwd: string; title?: string; pipeline?: string }) => + ensure: (body: { cwd?: string; title?: string; pipeline?: string }) => request<{ lane: Lane; created: boolean }>("/lanes/ensure", { method: "POST", body: JSON.stringify(body), diff --git a/docs/API.md b/docs/API.md index 7ef003c6..893071e3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -138,18 +138,23 @@ POST /api/lanes/ensure { "cwd": "/absolute/path/to/work", "title": "App package", "pipeline": "ship-feature" } ``` -`pipeline` is optional and, like `title`, applies **only when a lane is -actually created** — an existing lane is returned untouched. Omitted, the lane -gets `default`. `POST /api/lanes/worktree` and `POST /api/lanes` take the same -optional field. An id no template defines returns `400 EBADPIPELINE`. +`cwd` is optional — when missing or empty the server falls back to +`os.homedir()` (the user's home directory). This is how the Sagi mascot +adopts its dedicated lane without the client needing to know an absolute +path. Existing cwd values still pass through unchanged. `pipeline` is +optional and, like `title`, applies **only when a lane is actually created** +— an existing lane is returned untouched. Omitted, the lane gets `default`. +`POST /api/lanes/worktree` and `POST /api/lanes` take the same optional +field. An id no template defines returns `400 EBADPIPELINE`. Idempotent lookup by working directory — the Workspace page opens on a `cwd`, not on a lane id. Returns the lane whose own `cwd` is that path, or the longest path-boundary parent of it, as `{ "lane": {...}, "created": false }` with `200`. When no lane owns the path, an `adopted` lane is created and returned as `{ "lane": {...}, "created": true }` with `201`; `title` is used only in that -case. A relative or missing `cwd` returns `400 EBADCWD`. Path boundaries are -respected: a lane at `/tmp/wt` owns `/tmp/wt/pkg` but never `/tmp/wt-sibling`. +case. A *relative* `cwd` returns `400 EBADCWD`; a missing or empty `cwd` +falls back to the user's home directory instead of erroring. Path boundaries +are respected: a lane at `/tmp/wt` owns `/tmp/wt/pkg` but never `/tmp/wt-sibling`. Two concurrent calls for the same path always yield **one** lane: the `lanes.cwd` UNIQUE constraint decides the winner, and the loser re-reads and diff --git a/server/routes/lanes.js b/server/routes/lanes.js index ec1c9ae7..0ef1a888 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -161,11 +161,25 @@ router.get("/branches", async (req, res) => { */ router.post("/ensure", sameOriginGuard, (req, res) => { const body = req.body || {}; - const owner = lanesLib.resolveLaneByCwd(body.cwd); - if (owner) return res.json({ lane: payload(owner), created: false }); + // Default the cwd to the user's home so callers that don't care about a + // specific directory (e.g. Sagi's mascot companion) get a real, existing + // path and lane uniqueness stays meaningful. Callers that do care pass one + // through unchanged. + const cwd = typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir(); + const owner = lanesLib.resolveLaneByCwd(cwd); + if (owner) { + // First-write wins on the title: an ensure-with-title on an existing lane + // is how callers like Sagi adopt the lane, so don't clobber a real title + // with an empty one. + if (body.title && !owner.title) { + const updated = lanesLib.updateLane(owner.id, { title: body.title }); + return res.json({ lane: payload(updated || owner), created: false }); + } + return res.json({ lane: payload(owner), created: false }); + } try { const lane = lanesLib.createLane({ - cwd: body.cwd, + cwd, title: body.title || "", ...(body.pipeline ? { pipeline: body.pipeline } : {}), }); @@ -178,7 +192,7 @@ router.post("/ensure", sameOriginGuard, (req, res) => { // The cwd UNIQUE constraint is the arbiter: someone else won the race, so // re-read and return THEIR lane rather than reporting a conflict. if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) { - const winner = lanesLib.resolveLaneByCwd(body.cwd); + const winner = lanesLib.resolveLaneByCwd(cwd); if (winner) return res.json({ lane: payload(winner), created: false }); } return res.status(500).json({ error: { code: err.code, message: err.message } });