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:
+11
-4
@@ -2138,7 +2138,9 @@ The detection layer carries all of the signal value: the dashboard tells the use
|
|||||||
|
|
||||||
## Sagi Companion Subsystem
|
## 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.
|
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 --> AVATAR["SagiAvatar"]
|
||||||
DERIVED --> BUBBLE["SpeechBubble"]
|
DERIVED --> BUBBLE["SpeechBubble"]
|
||||||
DERIVED --> PANEL["SagiPanel"]
|
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 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
|
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. |
|
| **`SpeechBubble.tsx`** | Pure presentational speech bubble. |
|
||||||
| **`SagiPanel.tsx`** | Pure presentational panel: status readout + quick actions + the Ask box. |
|
| **`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. |
|
| **`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. |
|
| **`quips.ts`** | Pure mood → phrase pools. |
|
||||||
| **`prefs.ts`** | `localStorage`-backed enabled / muted preferences with cross-tab sync. |
|
| **`prefs.ts`** | `localStorage`-backed enabled / muted preferences with cross-tab sync. |
|
||||||
| **`sagi.css`** | Keyframes and per-mood expressions; selected via the `data-mood` attribute. |
|
| **`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/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/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). |
|
| `client/src/i18n/locales/{en,vi}/settings.json` | `sagi.*` strings for the Settings toggle (zh / ko fall back to the hardcoded English defaults). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ import { useSagiBrain } from "./useSagiBrain";
|
|||||||
import { useSagiPosition, SAGI_SIZE } from "./useSagiPosition";
|
import { useSagiPosition, SAGI_SIZE } from "./useSagiPosition";
|
||||||
import { matchIntent } from "./intents";
|
import { matchIntent } from "./intents";
|
||||||
import { sagiPrefs } from "./prefs";
|
import { sagiPrefs } from "./prefs";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
import "./sagi.css";
|
import "./sagi.css";
|
||||||
|
|
||||||
const FLYOUT_GAP = 10; // px between avatar and flyout
|
const FLYOUT_GAP = 10; // px between avatar and flyout
|
||||||
@@ -196,11 +197,31 @@ export function Sagi() {
|
|||||||
(query: string): string | null => {
|
(query: string): string | null => {
|
||||||
const result = matchIntent(query, brain.status);
|
const result = matchIntent(query, brain.status);
|
||||||
if (result.kind === "answer") return result.text;
|
if (result.kind === "answer") return result.text;
|
||||||
// Handoff: spawn a real claude via the existing Run page. `autostart=1`
|
// Handoff: ensure the "Sagi" lane exists for this dashboard, spawn a
|
||||||
// tells Run to fire the prompt automatically once it's prefilled, so the
|
// fresh claude session in it carrying the user's prompt as its first
|
||||||
// question is actually sent instead of just dropped into the composer.
|
// message, then jump to the Run page so the live pane is observable.
|
||||||
navigate(`/run?prompt=${encodeURIComponent(result.prompt)}&autostart=1`);
|
// Fire-and-forget — the panel already clears its composer on null and
|
||||||
setOpen(false);
|
// 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;
|
return null;
|
||||||
},
|
},
|
||||||
[brain, navigate]
|
[brain, navigate]
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ export interface SagiBrain {
|
|||||||
status: SagiStatus;
|
status: SagiStatus;
|
||||||
bubble: string | null;
|
bubble: string | null;
|
||||||
dismissBubble: () => void;
|
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;
|
muted: boolean;
|
||||||
toggleMute: () => void;
|
toggleMute: () => void;
|
||||||
clearAlerts: () => void;
|
clearAlerts: () => void;
|
||||||
@@ -103,7 +107,7 @@ export function useSagiBrain(): SagiBrain {
|
|||||||
connected: true,
|
connected: true,
|
||||||
}));
|
}));
|
||||||
const [tick, setTick] = useState(now0);
|
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 [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
|
||||||
|
|
||||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
@@ -121,8 +125,8 @@ export function useSagiBrain(): SagiBrain {
|
|||||||
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
|
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
|
||||||
lastBubbleAt.current = t;
|
lastBubbleAt.current = t;
|
||||||
clearTimeout(bubbleTimer.current);
|
clearTimeout(bubbleTimer.current);
|
||||||
setBubble(text);
|
setBubbleState(text);
|
||||||
bubbleTimer.current = setTimeout(() => setBubble(null), BUBBLE_MS);
|
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Seed from the REST snapshot on mount so counts are accurate immediately -
|
// Seed from the REST snapshot on mount so counts are accurate immediately -
|
||||||
@@ -176,7 +180,7 @@ export function useSagiBrain(): SagiBrain {
|
|||||||
|
|
||||||
const dismissBubble = useCallback(() => {
|
const dismissBubble = useCallback(() => {
|
||||||
clearTimeout(bubbleTimer.current);
|
clearTimeout(bubbleTimer.current);
|
||||||
setBubble(null);
|
setBubbleState(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleMute = useCallback(() => {
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1832,10 +1832,12 @@ export const api = {
|
|||||||
list: () => request<{ lanes: Lane[]; counts: LaneCounts }>("/lanes"),
|
list: () => request<{ lanes: Lane[]; counts: LaneCounts }>("/lanes"),
|
||||||
/**
|
/**
|
||||||
* POST /api/lanes/ensure — ensure a lane exists for the given cwd.
|
* 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.
|
* @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", {
|
request<{ lane: Lane; created: boolean }>("/lanes/ensure", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
|||||||
+11
-6
@@ -138,18 +138,23 @@ POST /api/lanes/ensure
|
|||||||
{ "cwd": "/absolute/path/to/work", "title": "App package", "pipeline": "ship-feature" }
|
{ "cwd": "/absolute/path/to/work", "title": "App package", "pipeline": "ship-feature" }
|
||||||
```
|
```
|
||||||
|
|
||||||
`pipeline` is optional and, like `title`, applies **only when a lane is
|
`cwd` is optional — when missing or empty the server falls back to
|
||||||
actually created** — an existing lane is returned untouched. Omitted, the lane
|
`os.homedir()` (the user's home directory). This is how the Sagi mascot
|
||||||
gets `default`. `POST /api/lanes/worktree` and `POST /api/lanes` take the same
|
adopts its dedicated lane without the client needing to know an absolute
|
||||||
optional field. An id no template defines returns `400 EBADPIPELINE`.
|
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`,
|
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
|
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`.
|
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
|
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
|
`{ "lane": {...}, "created": true }` with `201`; `title` is used only in that
|
||||||
case. A relative or missing `cwd` returns `400 EBADCWD`. Path boundaries are
|
case. A *relative* `cwd` returns `400 EBADCWD`; a missing or empty `cwd`
|
||||||
respected: a lane at `/tmp/wt` owns `/tmp/wt/pkg` but never `/tmp/wt-sibling`.
|
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
|
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
|
`lanes.cwd` UNIQUE constraint decides the winner, and the loser re-reads and
|
||||||
|
|||||||
+18
-4
@@ -161,11 +161,25 @@ router.get("/branches", async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.post("/ensure", sameOriginGuard, (req, res) => {
|
router.post("/ensure", sameOriginGuard, (req, res) => {
|
||||||
const body = req.body || {};
|
const body = req.body || {};
|
||||||
const owner = lanesLib.resolveLaneByCwd(body.cwd);
|
// Default the cwd to the user's home so callers that don't care about a
|
||||||
if (owner) return res.json({ lane: payload(owner), created: false });
|
// 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 {
|
try {
|
||||||
const lane = lanesLib.createLane({
|
const lane = lanesLib.createLane({
|
||||||
cwd: body.cwd,
|
cwd,
|
||||||
title: body.title || "",
|
title: body.title || "",
|
||||||
...(body.pipeline ? { pipeline: body.pipeline } : {}),
|
...(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
|
// The cwd UNIQUE constraint is the arbiter: someone else won the race, so
|
||||||
// re-read and return THEIR lane rather than reporting a conflict.
|
// re-read and return THEIR lane rather than reporting a conflict.
|
||||||
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
|
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 });
|
if (winner) return res.json({ lane: payload(winner), created: false });
|
||||||
}
|
}
|
||||||
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||||
|
|||||||
Reference in New Issue
Block a user