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.
This commit is contained in:
2026-08-24 09:39:28 +07:00
parent 510f015fdf
commit b1bca07c47
6 changed files with 99 additions and 26 deletions
+11 -4
View File
@@ -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=…<br/>(existing Run page)"]
PANEL -->|"unmatched Ask"| ENSURE["POST /api/lanes/ensure<br/>(adopt 'Sagi' lane, cwd = home)"]
ENSURE --> START["POST /api/run<br/>(spawn tmux-backed claude<br/>in Sagi lane, initialPrompt=…)"]
START --> RUN["/run?laneId=…<br/>(existing Run page picks up<br/>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 `<Sagi />` once, as a sibling of `<UpdateNotifier />`. |
| `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). |
---
+26 -5
View File
@@ -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]
+29 -5
View File
@@ -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<string | null>(null);
const [bubble, setBubbleState] = useState<string | null>(null);
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
@@ -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,
};
}
+4 -2
View File
@@ -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),
+11 -6
View File
@@ -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
+18 -4
View File
@@ -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 } });