feat: pet chat window replaces the Sagi panel; Windows pane attach no longer crashes the server

- Sagi: clicking the pet opens its chat window directly (PetChat + SagiInfo
  status strip, Needs you, Mute / Clear alerts); SagiPanel removed.
- Chat view answers Claude's unnumbered folder-trust prompt (parseBareMenu).
- pty-attach: resize/write after a Windows attach exited, and node-pty's pipe
  errors, no longer throw out of the WebSocket handler.
- restart-helper: the server started by an update logs to
  data/agenthub-server.log instead of discarding its output.
This commit is contained in:
2026-10-16 10:27:22 +07:00
parent a58e95a957
commit f460fff406
16 changed files with 734 additions and 555 deletions
+8 -8
View File
@@ -2325,7 +2325,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
- **Branch coverage.** A feature branch or detached HEAD would need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon — which is why those situations get a printed `git fetch` and no button. - **Branch coverage.** A feature branch or detached HEAD would need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon — which is why those situations get a printed `git fetch` and no button.
- **Ownership.** A plugin-cache install is not ours to rewrite by hand — its directory names are content hashes Claude Code manages. So that case does not fast-forward anything; it calls `claude plugin update` and then re-runs `plugin-bootstrap.js` from the directory that tool produced. The residual limit is only `/reload-plugins`, which is REPL-scoped: an interactive session keeps its old skills/commands until the user reloads, even though the dashboard server has already moved. - **Ownership.** A plugin-cache install is not ours to rewrite by hand — its directory names are content hashes Claude Code manages. So that case does not fast-forward anything; it calls `claude plugin update` and then re-runs `plugin-bootstrap.js` from the directory that tool produced. The residual limit is only `/reload-plugins`, which is REPL-scoped: an interactive session keeps its old skills/commands until the user reloads, even though the dashboard server has already moved.
What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js`: a server our own service unit runs (its cgroup ends in `/agenthub.service`, or it is our launchd job) is restarted BY that manager (`systemctl restart --no-block`, via `sudo -n` for the server-mode system unit; launchd's KeepAlive after an exit), anything else by the detached `scripts/restart-helper.js`. A detached replacement under a `Restart=always` unit held the port while the unit restarted every 3 s, each start exiting "already running" (v2.7.2). What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js`: a server our own service unit runs (its cgroup ends in `/agenthub.service`, or it is our launchd job) is restarted BY that manager (`systemctl restart --no-block`, via `sudo -n` for the server-mode system unit; launchd's KeepAlive after an exit), anything else by the detached `scripts/restart-helper.js`, which appends the new server's stdout/stderr to `data/agenthub-server.log` like `agenthub start` (it used to discard them, so a crash after an update left no stack trace). A detached replacement under a `Restart=always` unit held the port while the unit restarted every 3 s, each start exiting "already running" (v2.7.2).
--- ---
@@ -2356,7 +2356,7 @@ graph TD
SHELL["Sagi.tsx<br/>shell: open/closed state,<br/>⌘B / Esc, reduced-motion,<br/>navigation"] SHELL["Sagi.tsx<br/>shell: open/closed state,<br/>⌘B / Esc, reduced-motion,<br/>navigation"]
AVATAR["SagiAvatar.tsx<br/>SVG mascot; data-mood drives CSS;<br/>cursor-tracking pupils"] AVATAR["SagiAvatar.tsx<br/>SVG mascot; data-mood drives CSS;<br/>cursor-tracking pupils"]
BUBBLE["SpeechBubble.tsx<br/>bubble"] BUBBLE["SpeechBubble.tsx<br/>bubble"]
PANEL["SagiPanel.tsx<br/>status + Needs you + Mute / Clear alerts + Ask box"] PANEL["PetChat.tsx + SagiInfo.tsx<br/>the chat window: status strip, Needs you,<br/>Mute / Clear alerts, local answers, AI chat"]
CSS["sagi.css<br/>keyframes + per-mood expressions"] CSS["sagi.css<br/>keyframes + per-mood expressions"]
end end
@@ -2386,8 +2386,8 @@ flowchart LR
SUB --> DERIVED["derived state<br/>{ mood, status, bubble }"] SUB --> DERIVED["derived state<br/>{ mood, status, bubble }"]
DERIVED --> AVATAR["SagiAvatar"] DERIVED --> AVATAR["SagiAvatar"]
DERIVED --> BUBBLE["SpeechBubble"] DERIVED --> BUBBLE["SpeechBubble"]
DERIVED --> PANEL["SagiPanel"] DERIVED --> PANEL["PetChat (+ SagiInfo)"]
PANEL -->|"unmatched Ask"| ENSURE["POST /api/lanes/ensure<br/>(adopt 'Sagi' lane, cwd = home)"] PANEL -->|"unmatched question"| 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=…)"] 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)"] START --> RUN["/run?laneId=…<br/>(existing Run page picks up<br/>the live run)"]
@@ -2407,10 +2407,10 @@ The mood state machine in `deriveMood` resolves to a single expression using a f
| **`useSagiBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech bubble (naming the session it is about; the same pulse again within 8 s joins it as a count), the one-shot `moment` animation, a once-per-browser-session greeting, an idle fidget every minute or so, pat, mute, and clear-alerts (the dismissed ids persist in `localStorage` `agenthub.pet.dismissedErrors`, newest 200). The reducer runs outside React's updater so StrictMode never doubles a pulse. Produces `{ mood, status, attention, bubble, moment }`. | | **`useSagiBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech bubble (naming the session it is about; the same pulse again within 8 s joins it as a count), the one-shot `moment` animation, a once-per-browser-session greeting, an idle fidget every minute or so, pat, mute, and clear-alerts (the dismissed ids persist in `localStorage` `agenthub.pet.dismissedErrors`, newest 200). The reducer runs outside React's updater so StrictMode never doubles a pulse. Produces `{ mood, status, attention, bubble, moment }`. |
| **`SagiAvatar.tsx`** | Pure presentational SVG mascot. The `data-mood` attribute drives CSS; pupils track the cursor. Rendered at 90×90 px (`size` default, kept in sync with `SAGI_SIZE` in `useSagiPosition.ts`, which is the draggable button's real footprint) through a `viewBox` cropped to the artwork's measured bounding box instead of the exporter's padded `0 0 1280 1280` canvas. Path data is never rescaled, so `MAX_PUPIL_SHIFT` stays in the original 1280-unit space. The `transform-origin` values in `sagi.css` do **not**: an origin on an inner SVG element resolves against `transform-box: view-box`, whose 0,0 sits at the `viewBox` min (`253.5 106`), so those are mascot coordinates **minus that offset**. The root `.sagi-avatar` origin is a percentage on purpose — on the outermost `<svg>` a length would be measured against the 68 px CSS box, and the resulting ~9× overshoot turns every mood animation into a whole-avatar lurch. | | **`SagiAvatar.tsx`** | Pure presentational SVG mascot. The `data-mood` attribute drives CSS; pupils track the cursor. Rendered at 90×90 px (`size` default, kept in sync with `SAGI_SIZE` in `useSagiPosition.ts`, which is the draggable button's real footprint) through a `viewBox` cropped to the artwork's measured bounding box instead of the exporter's padded `0 0 1280 1280` canvas. Path data is never rescaled, so `MAX_PUPIL_SHIFT` stays in the original 1280-unit space. The `transform-origin` values in `sagi.css` do **not**: an origin on an inner SVG element resolves against `transform-box: view-box`, whose 0,0 sits at the `viewBox` min (`253.5 106`), so those are mascot coordinates **minus that offset**. The root `.sagi-avatar` origin is a percentage on purpose — on the outermost `<svg>` a length would be measured against the 68 px CSS box, and the resulting ~9× overshoot turns every mood animation into a whole-avatar lurch. |
| **`SpeechBubble.tsx`** | Pure presentational speech bubble: the line, the session(s) it is about, and an **Open** button for a single one (`workspaceHref` — its live pane, else its replay, else `/sessions/<id>`; never a resume). | | **`SpeechBubble.tsx`** | Pure presentational speech bubble: the line, the session(s) it is about, and an **Open** button for a single one (`workspaceHref` — its live pane, else its replay, else `/sessions/<id>`; never a resume). |
| **`SagiPanel.tsx`** | Pure presentational panel: the pet's name (click to rename — `agenthub.pet.names`, synced to the account), a pat button, status readout, a **Needs you** list of errored/waiting sessions by name, Mute and Clear alerts, and the Ask box. | | **`SagiInfo.tsx`** | Pure presentational pieces of the chat window: `PetNameButton` (click to rename — `agenthub.pet.names`, synced to the account) and `SagiInfo`, the status strip under the header — live / waiting / errored and the connection always shown; expanded (by default while something needs you) a **Needs you** list of errored/waiting sessions by name, Mute and Clear alerts. |
| **`PetChat.tsx`** | The pet's chat window (Ask handoff, or **Chat with …** in the panel), styled as a soft accent gradient with chat bubbles. The first message starts a session of the user's default AI tool (`agenthub.defaultTool`; Claude or Codex — any other tool still opens its pane in the workspace) in the "Sagi" workspace's first free tab; replies are read from the session's transcript every 1.5 s, and follow-ups and menu answers (`PaneMenuCard`) go through a `TerminalView` attached off-screen (`submitToRun`), so no new route exists. The chat is kept in `localStorage` (`agenthub.pet.chat`) while its pane lives; an ended pane is never resumed — the next message starts a new session. | | **`PetChat.tsx`** | The pet's whole UI — clicking the pet opens it (there is no separate panel): header (name, pat heart, Open in workspace, New chat, close), the `SagiInfo` strip, then the conversation. Before a chat runs, a question `intents.ts` can answer is answered in place (`onLocal`); anything else goes to the AI. Styled as a soft accent gradient with chat bubbles. The first message starts a session of the user's default AI tool (`agenthub.defaultTool`; Claude or Codex — any other tool still opens its pane in the workspace) in the "Sagi" workspace's first free tab; replies are read from the session's transcript every 1.5 s, and follow-ups and menu answers (`PaneMenuCard` — numbered menus and Claude's unnumbered folder-trust prompt alike) go through a `TerminalView` attached off-screen (`submitToRun`), so no new route exists. The chat is kept in `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and reopening the window continues it; **New chat** stops that session (`DELETE /api/run/:id?archive=1`, asking first only mid-reply); an ended pane is never resumed — the next message starts a new session. |
| **`Sagi.tsx`** | Shell component. A pat (the panel's heart, the Ask box, or stroking the pet — four left/right turns of the pointer over it within 1.2 s) shows hearts and a line ON the pet, so it reads while the panel or chat is open. 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. A pat (the chat's heart, a typed pat, or stroking the pet — four left/right turns of the pointer over it within 1.2 s) shows hearts and a line ON the pet, so it reads while the chat is open. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns the chat's open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. |
| **`intents.ts`** | Local Q&A over the cached status snapshot, in English and Vietnamese keywords, answering in the UI language and naming the sessions it counts (also: who are you, hello, thanks, pat). Queries that don't match a local intent open `PetChat`; with a default tool it cannot read back, `Sagi.tsx` instead 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. | | **`intents.ts`** | Local Q&A over the cached status snapshot, in English and Vietnamese keywords, answering in the UI language and naming the sessions it counts (also: who are you, hello, thanks, pat). Queries that don't match a local intent go to `PetChat`'s AI session; with a default tool it cannot read back, `Sagi.tsx` instead 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`** | Mood/pulse → phrase pools, plus greetings and pat lines, read from `settings:sagi.{quips,greet,pat}` so they follow the UI language. | | **`quips.ts`** | Mood/pulse → phrase pools, plus greetings and pat lines, read from `settings:sagi.{quips,greet,pat}` so they follow the UI language. |
| **`prefs.ts`** | `localStorage`-backed enabled / muted / pet / size / nickname preferences with cross-tab sync; `petName` = nickname, else the worn pet's own name, else Sagi. | | **`prefs.ts`** | `localStorage`-backed enabled / muted / pet / size / nickname preferences with cross-tab sync; `petName` = nickname, else the worn pet's own name, else Sagi. |
| **`sagi.css`** | Keyframes and per-mood expressions (`data-mood`), plus the one-shot moments (`data-moment`: jump, wave, fail, wait, review) on Sagi's art and an uploaded image. | | **`sagi.css`** | Keyframes and per-mood expressions (`data-mood`), plus the one-shot moments (`data-moment`: jump, wave, fail, wait, review) on Sagi's art and an uploaded image. |
+82 -32
View File
@@ -1,7 +1,11 @@
/** /**
* @file PetChat.tsx * @file PetChat.tsx
* @description The pet's chat window. A question its Ask box cannot answer * @description The pet's chat window — its whole UI: clicking the pet opens
* locally opens here instead of leaving the page: the first message starts a * it. Above the conversation sits what the pet knows (`info`: live / waiting /
* errored, the sessions that need you, Mute, Clear alerts — `SagiInfo.tsx`).
* Before a chat runs, a question the pet can answer itself (`onLocal`: status,
* who are you…) is answered right here; anything else — and every message once
* a chat runs — goes to the AI: the first message starts a
* session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or * session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its * Codex — the two with a readable transcript) in the "Sagi" workspace, in its
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as * first free tab (`startInFreeTabDetailed`), and the conversation is shown as
@@ -11,16 +15,17 @@
* attached pane, exactly like the workspace's own Chat view), so nothing new * attached pane, exactly like the workspace's own Chat view), so nothing new
* reaches the server. A menu the CLI asks with (a permission or trust prompt) * reaches the server. A menu the CLI asks with (a permission or trust prompt)
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in * is read off that pane and answered with `PaneMenuCard`. The chat is kept in
* `localStorage` (`agenthub.pet.chat`) while its pane lives; **New chat** * `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and
* forgets it (the session stays in the workspace), **Open in workspace** * reopening the window continues it; **New chat** STOPS that session (kills
* its pane and archives it, asking first only mid-reply), **Open in workspace**
* opens its pane there. Opening never resumes an ended session: once the pane * opens its pane there. Opening never resumes an ended session: once the pane
* is gone, the next message starts a new one. * is gone, the next message starts a new one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ArrowLeft, ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react"; import { ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import type { TranscriptMessage } from "../../lib/types"; import type { TranscriptMessage } from "../../lib/types";
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults"; import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
@@ -33,6 +38,8 @@ import {
type PaneMenu, type PaneMenu,
} from "../../lib/panePaste"; } from "../../lib/panePaste";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast";
import { startInFreeTabDetailed } from "../lanes/startSession"; import { startInFreeTabDetailed } from "../lanes/startSession";
import { TerminalView } from "../run/TerminalView"; import { TerminalView } from "../run/TerminalView";
import { PaneMenuCard } from "../run/PaneChat"; import { PaneMenuCard } from "../run/PaneChat";
@@ -141,14 +148,21 @@ function toolSummary(names: string[]): string {
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · "); return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · ");
} }
/** What the container made of a message: an answer the pet gives itself, or
* `handled` (it took the message elsewhere); null sends it to the AI. */
export type PetLocalReply = { answer: string } | { handled: true } | null;
interface PetChatProps { interface PetChatProps {
/** The pet's shown name, for the header and greeting. */ /** The pet's shown name, for the header and greeting. */
name: string; name: string;
/** A question to send as soon as the chat opens (the Ask box's handoff). */ /** Shown in place of the plain name (the rename button). */
initialPrompt?: string | null; title?: ReactNode;
/** Called once the initial prompt has been taken, so a re-render never sends it twice. */ /** Extra header buttons, before Open in workspace (the pat heart). */
onPromptTaken?: () => void; headerActions?: ReactNode;
onBack: () => void; /** Under the header: what the pet knows (status, sessions that need you). */
info?: ReactNode;
/** Asked first while no chat runs. */
onLocal?: (text: string) => PetLocalReply;
onClose: () => void; onClose: () => void;
/** Opens the chat's pane in its workspace. */ /** Opens the chat's pane in its workspace. */
onOpenWorkspace: (laneId: number, runId: string) => void; onOpenWorkspace: (laneId: number, runId: string) => void;
@@ -158,9 +172,10 @@ interface PetChatProps {
export function PetChat({ export function PetChat({
name, name,
initialPrompt, title,
onPromptTaken, headerActions,
onBack, info,
onLocal,
onClose, onClose,
onOpenWorkspace, onOpenWorkspace,
onBusy, onBusy,
@@ -175,6 +190,8 @@ export function PetChat({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [menu, setMenu] = useState<PaneMenu | null>(null); const [menu, setMenu] = useState<PaneMenu | null>(null);
// The pet's own answers, given before any chat ran.
const [local, setLocal] = useState<Line[]>([]);
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const remember = (ref: PetChatRef | null) => { const remember = (ref: PetChatRef | null) => {
@@ -248,7 +265,7 @@ export function PetChat({
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [chat]); }, [chat]);
const lines = chatLines(messages); const lines = [...local, ...chatLines(messages)];
// The pending line shows until the transcript has it. // The pending line shows until the transcript has it.
useEffect(() => { useEffect(() => {
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim())) if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
@@ -269,6 +286,19 @@ export function PetChat({
const prompt = text.trim(); const prompt = text.trim();
if (!prompt || starting) return; if (!prompt || starting) return;
setError(null); setError(null);
if (!chat && onLocal) {
const reply = onLocal(prompt);
if (reply && "handled" in reply) return;
if (reply) {
const n = Date.now();
setLocal((prev) => [
...prev,
{ key: `lu${n}`, role: "user", text: prompt },
{ key: `la${n}`, role: "assistant", text: reply.answer },
]);
return;
}
}
setPending(prompt); setPending(prompt);
if (chat) { if (chat) {
// Typed into the attached pane; it may still be attaching. // Typed into the attached pane; it may still be attaching.
@@ -302,17 +332,35 @@ export function PetChat({
} }
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[chat, starting, t] [chat, starting, t, onLocal]
); );
// The Ask box's question, sent once. // New chat: the old one's session is stopped, not left running unseen —
const taken = useRef(false); // asking first only while it is still writing a reply.
useEffect(() => { const newChat = async () => {
if (!initialPrompt || taken.current) return; if (chat) {
taken.current = true; if (
onPromptTaken?.(); busy &&
void send(initialPrompt); !(await confirmDialog({
}, [initialPrompt, onPromptTaken, send]); title: t("sagi.chat.endTitle"),
message: t("sagi.chat.endBody"),
confirmLabel: t("sagi.chat.endConfirm"),
destructive: true,
}))
)
return;
try {
await api.run.kill(chat.runId, { archive: true });
} catch (err) {
toast.error(err);
return;
}
}
remember(null);
setLocal([]);
setPending(null);
setError(null);
};
const submit = (e?: FormEvent) => { const submit = (e?: FormEvent) => {
e?.preventDefault(); e?.preventDefault();
@@ -333,11 +381,11 @@ export function PetChat({
> >
{/* header */} {/* header */}
<div className="pet-chat-head flex items-center gap-1 px-2 py-2"> <div className="pet-chat-head flex items-center gap-1 px-2 py-2">
<IconButton label={t("sagi.chat.back")} onClick={onBack}> <span className="pl-1.5 text-base leading-none" aria-hidden>
<ArrowLeft size={16} /> 🐾
</IconButton> </span>
<div className="min-w-0 flex-1 px-1"> <div className="min-w-0 flex-1 px-1">
<div className="truncate text-sm font-semibold text-fg-primary">{name}</div> {title ?? <div className="truncate text-sm font-semibold text-fg-primary">{name}</div>}
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted"> <div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
<span <span
className={`inline-block h-1.5 w-1.5 rounded-full ${ className={`inline-block h-1.5 w-1.5 rounded-full ${
@@ -352,6 +400,7 @@ export function PetChat({
{busy ? t("sagi.chat.typing") : toolLabel} {busy ? t("sagi.chat.typing") : toolLabel}
</div> </div>
</div> </div>
{headerActions}
{chat && ( {chat && (
<IconButton <IconButton
label={t("sagi.chat.openWorkspace")} label={t("sagi.chat.openWorkspace")}
@@ -362,8 +411,8 @@ export function PetChat({
)} )}
<IconButton <IconButton
label={t("sagi.chat.new")} label={t("sagi.chat.new")}
onClick={() => remember(null)} onClick={() => void newChat()}
disabled={!chat || starting} disabled={(!chat && !local.length) || starting}
> >
<SquarePen size={15} /> <SquarePen size={15} />
</IconButton> </IconButton>
@@ -372,6 +421,8 @@ export function PetChat({
</IconButton> </IconButton>
</div> </div>
{info}
{/* conversation */} {/* conversation */}
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3"> <div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
{empty && ( {empty && (
@@ -475,10 +526,9 @@ export function PetChat({
e.preventDefault(); e.preventDefault();
submit(); submit();
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
// Esc leaves the chat for the panel, not the whole mascot.
e.stopPropagation(); e.stopPropagation();
e.nativeEvent.stopImmediatePropagation(); e.nativeEvent.stopImmediatePropagation();
onBack(); onClose();
} }
}} }}
/> />
+62 -65
View File
@@ -5,17 +5,20 @@
* enabled; `Sagi` gates on the same preference itself, and the body (brain, * enabled; `Sagi` gates on the same preference itself, and the body (brain,
* tick, WS reducer) runs only while it is on. Persists across routes and * tick, WS reducer) runs only while it is on. Persists across routes and
* shares the single WebSocket. * shares the single WebSocket.
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion * Clicking the pet opens its chat window (`PetChat`), which is its whole UI:
* the status strip (`SagiInfo`) sits on top, a question the pet can answer
* itself is answered there, anything else goes to the AI.
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
* detection, and route navigation. Reactive personality + status/Ask come * detection, and route navigation. Reactive personality + status/Ask come
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via * from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
* useSagiPosition (a v2 Codex pet also looks toward the pointer), and the * useSagiPosition (a v2 Codex pet also looks toward the pointer), and the
* bubble/panel render in a self-clamping flyout so * bubble/panel render in a self-clamping flyout so
* they never spill off any screen edge regardless of where the mascot is docked. * they never spill off any screen edge regardless of where the mascot is docked.
* *
* The "do the job" path: an unmatched Ask query starts a claude session in * The "do the job" path: an unmatched question is the chat's — it starts a
* the "Sagi" workspace carrying it as the first prompt, then opens that * session of the default AI tool in the "Sagi" workspace; a default tool the
* workspace's session view (`/workspace?lane=<id>&run=<runId>`) — no new LLM * chat cannot read back starts there and its session view opens
* backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not * (`/workspace?lane=<id>&run=<runId>`) — no new LLM backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
* in an editable field (a terminal's Ctrl+B is tmux's prefix). * in an editable field (a terminal's Ctrl+B is tmux's prefix).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -39,7 +42,7 @@
* ## Internal dependencies * ## Internal dependencies
* - `./SagiAvatar` * - `./SagiAvatar`
* - `./SpeechBubble` * - `./SpeechBubble`
* - `./SagiPanel` * - `./SagiInfo`
* - `./useSagiBrain` * - `./useSagiBrain`
* - `./useSagiPosition` * - `./useSagiPosition`
* - `./intents` * - `./intents`
@@ -83,7 +86,7 @@ import { SagiAvatar } from "./SagiAvatar";
import { PetSprite } from "./PetSprite"; import { PetSprite } from "./PetSprite";
import { PetMenu } from "./PetMenu"; import { PetMenu } from "./PetMenu";
import { SpeechBubble } from "./SpeechBubble"; import { SpeechBubble } from "./SpeechBubble";
import { SagiPanel } from "./SagiPanel"; import { PetNameButton, SagiInfo } from "./SagiInfo";
import { useSagiBrain } from "./useSagiBrain"; import { useSagiBrain } from "./useSagiBrain";
import { useSagiPosition } from "./useSagiPosition"; import { useSagiPosition } from "./useSagiPosition";
import { useSagiEnabled } from "./SagiMount"; import { useSagiEnabled } from "./SagiMount";
@@ -94,7 +97,8 @@ import { api } from "../../lib/api";
import { readDefaultTool } from "../../lib/sessionDefaults"; import { readDefaultTool } from "../../lib/sessionDefaults";
import { startInFreeTabDetailed } from "../lanes/startSession"; import { startInFreeTabDetailed } from "../lanes/startSession";
import { workspaceHref } from "../SessionRowMenu"; import { workspaceHref } from "../SessionRowMenu";
import { PetChat, petChatTool } from "./PetChat"; import { Heart } from "lucide-react";
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat";
// Stroking: this many left/right turns of the pointer over the pet, within // Stroking: this many left/right turns of the pointer over the pet, within
// STROKE_MS and with no button held, is a pat; then a pause before the next. // STROKE_MS and with no button held, is a pat; then a pause before the next.
@@ -241,10 +245,8 @@ function SagiBody() {
// The name it goes by: the user's nickname, the pet's own, else Sagi. // The name it goes by: the user's nickname, the pet's own, else Sagi.
const [name, setName] = useState(() => petName(sagiPrefs.getPet())); const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
const [size, setSize] = useState(() => sagiPrefs.getSize()); const [size, setSize] = useState(() => sagiPrefs.getSize());
// The chat window (PetChat.tsx): the pet's whole UI.
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// The chat window (PetChat.tsx), and the Ask question it should send.
const [chatOpen, setChatOpen] = useState(false);
const [chatPrompt, setChatPrompt] = useState<string | null>(null);
// The right-click pet menu (`PetMenu.tsx`). // The right-click pet menu (`PetMenu.tsx`).
const [menu, setMenu] = useState(false); const [menu, setMenu] = useState(false);
const [hover, setHover] = useState(false); const [hover, setHover] = useState(false);
@@ -296,7 +298,6 @@ function SagiBody() {
setOpen((v) => !v); setOpen((v) => !v);
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
setOpen(false); setOpen(false);
setChatOpen(false);
} }
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
@@ -346,7 +347,6 @@ function SagiBody() {
navigate(workspaceHref(s, runs) ?? `/sessions/${encodeURIComponent(s.id)}`); navigate(workspaceHref(s, runs) ?? `/sessions/${encodeURIComponent(s.id)}`);
brain.dismissBubble(); brain.dismissBubble();
setOpen(false); setOpen(false);
setChatOpen(false);
}; };
api.run api.run
.list() .list()
@@ -356,21 +356,17 @@ function SagiBody() {
[brain, navigate] [brain, navigate]
); );
const onAsk = useCallback( // Asked by the chat before any session runs.
(query: string): string | null => { const onLocal = useCallback(
(query: string): PetLocalReply => {
const result = matchIntent(query, brain.status, { name, attention: brain.attention }); const result = matchIntent(query, brain.status, { name, attention: brain.attention });
if (result.kind === "answer") { if (result.kind === "answer") {
if (result.moment) brain.playMoment(result.moment); if (result.moment) brain.playMoment(result.moment);
return result.text; return { answer: result.text };
}
// Handoff: a question the pet cannot answer goes to the chat window,
// which starts a session of the default AI tool and shows it as chat.
if (petChatTool()) {
setChatPrompt(result.prompt);
setOpen(false);
setChatOpen(true);
return null;
} }
// A question the pet cannot answer: the chat starts a session of the
// default AI tool and shows it as chat.
if (petChatTool()) return null;
// A default tool the chat cannot read back (not Claude/Codex): start it // A default tool the chat cannot read back (not Claude/Codex): start it
// in the "Sagi" workspace and open its pane there, as before. // in the "Sagi" workspace and open its pane there, as before.
const prompt = result.prompt; const prompt = result.prompt;
@@ -392,7 +388,7 @@ function SagiBody() {
brain.setThinking(false); brain.setThinking(false);
} }
})(); })();
return null; return { handled: true };
}, },
[brain, navigate, t, name] [brain, navigate, t, name]
); );
@@ -431,46 +427,49 @@ function SagiBody() {
return ( return (
<> <>
{/* The panel is too big to drag around, so it hides mid-drag. The bubble
does not - it is Sagi talking, and it has to stay by its mouth. */}
{!place.dragging && open && (
<SagiFlyout anchor={anchor}>
<SagiPanel
name={name}
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
nickname={sagiPrefs.getNickname(pet.id)}
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
onPat={brain.pat}
attention={brain.attention}
onOpenSession={openSession}
status={brain.status}
muted={brain.muted}
onToggleMute={brain.toggleMute}
onClearAlerts={brain.clearAlerts}
onNavigate={onNavigate}
onAsk={onAsk}
onClose={() => setOpen(false)}
/>
</SagiFlyout>
)}
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows {/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
it 1:1), and kept mounted: unmounting would detach its terminal and it 1:1). Closing unmounts it; its chat is remembered, so reopening
lose a half-typed message. */} re-attaches the same session. */}
{chatOpen && ( {open && (
<SagiFlyout anchor={anchor} beside> <SagiFlyout anchor={anchor} beside>
<PetChat <PetChat
name={name} name={name}
initialPrompt={chatPrompt} title={
onPromptTaken={() => setChatPrompt(null)} <PetNameButton
onBack={() => { name={name}
setChatOpen(false); ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
setOpen(true); nickname={sagiPrefs.getNickname(pet.id)}
}} onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
onClose={() => setChatOpen(false)} />
}
headerActions={
<button
type="button"
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
onClick={brain.pat}
aria-label={t("sagi.panel.pat", { name })}
title={t("sagi.panel.pat", { name })}
>
<Heart size={15} />
</button>
}
info={
<SagiInfo
attention={brain.attention}
onOpenSession={openSession}
status={brain.status}
muted={brain.muted}
onToggleMute={brain.toggleMute}
onClearAlerts={brain.clearAlerts}
onNavigate={onNavigate}
defaultOpen={brain.attention.length > 0}
/>
}
onLocal={onLocal}
onClose={() => setOpen(false)}
onOpenWorkspace={(laneId, runId) => { onOpenWorkspace={(laneId, runId) => {
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`); navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
setChatOpen(false); setOpen(false);
}} }}
onBusy={onBusy} onBusy={onBusy}
/> />
@@ -491,7 +490,7 @@ function SagiBody() {
</SagiFlyout> </SagiFlyout>
)} )}
{!open && !menu && !chatOpen && brain.bubble && ( {!open && !menu && brain.bubble && (
<SagiFlyout anchor={anchor}> <SagiFlyout anchor={anchor}>
<SpeechBubble <SpeechBubble
bubble={brain.bubble} bubble={brain.bubble}
@@ -519,18 +518,16 @@ function SagiBody() {
// A drag just ended - swallow the synthetic click so the panel // A drag just ended - swallow the synthetic click so the panel
// doesn't toggle when the user only repositioned the avatar. // doesn't toggle when the user only repositioned the avatar.
if (place.consumeDrag()) return; if (place.consumeDrag()) return;
if (chatOpen) setChatOpen(false); setOpen((v) => !v);
else setOpen((v) => !v);
}} }}
// Right-click: the pet menu (hide, size, choose, upload, import). // Right-click: the pet menu (hide, size, choose, upload, import).
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
setOpen(false); setOpen(false);
setChatOpen(false);
setMenu(true); setMenu(true);
}} }}
aria-label={open || chatOpen ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })} aria-label={open ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
aria-expanded={open || chatOpen} aria-expanded={open}
title={t("sagi.avatarTitle", { name })} title={t("sagi.avatarTitle", { name })}
> >
{pet.id === "sagi" || !("kind" in pet) ? ( {pet.id === "sagi" || !("kind" in pet) ? (
+297
View File
@@ -0,0 +1,297 @@
/**
* @file SagiInfo.tsx
* @description What the pet knows, shown inside its chat window (`PetChat`):
* the name you rename by clicking it (`PetNameButton`), and a status strip
* (`SagiInfo`) — live / waiting / errored counts and the connection state
* always in sight, and on expanding, the sessions that need the user, by
* name, plus Mute and Clear alerts. Pure presentational: all data and
* behavior are injected by the container (`Sagi.tsx`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import {
AlertTriangle,
Bell,
BellOff,
ChevronDown,
ChevronRight,
Hourglass,
Radio,
Trash2,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
import { PET_NAME_MAX } from "./prefs";
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
const ATTENTION_SHOWN = 4;
/** The pet's name; a click renames it in place (empty = its own name). */
export function PetNameButton({
name,
ownName,
nickname,
onRename,
}: {
name: string;
ownName: string;
nickname: string;
onRename: (name: string) => void;
}) {
const { t } = useTranslation("settings");
// The name being typed, or null while not renaming.
const [renaming, setRenaming] = useState<string | null>(null);
const save = () => {
if (renaming === null) return;
onRename(renaming);
setRenaming(null);
};
return renaming === null ? (
<button
type="button"
className="max-w-full truncate rounded px-0.5 text-sm font-semibold text-fg-primary hover:bg-surface-4"
onClick={() => setRenaming(nickname)}
aria-label={t("sagi.panel.rename", { name })}
title={t("sagi.panel.rename", { name })}
>
{name}
</button>
) : (
<input
autoFocus
className="w-28 rounded border border-accent bg-surface-1 px-1 py-0.5 text-sm text-fg-primary focus:outline-none"
value={renaming}
maxLength={PET_NAME_MAX}
placeholder={ownName}
aria-label={t("sagi.panel.renameLabel")}
title={t("sagi.panel.renameHint")}
onChange={(e) => setRenaming(e.target.value)}
onBlur={save}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
save();
} else if (e.key === "Escape") {
// Esc cancels the rename only, not the whole chat.
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
setRenaming(null);
}
}}
/>
);
}
interface SagiInfoProps {
attention: ReadonlyArray<AttentionItem>;
onOpenSession: (s: SagiSubject) => void;
status: SagiStatus;
muted: boolean;
onToggleMute: () => void;
onClearAlerts: () => void;
onNavigate: (route: string) => void;
/** Expanded at first: the sessions that need the user and the actions. */
defaultOpen?: boolean;
}
/** The status strip under the chat's header. */
export function SagiInfo({
attention,
onOpenSession,
status,
muted,
onToggleMute,
onClearAlerts,
onNavigate,
defaultOpen = false,
}: SagiInfoProps) {
const { t } = useTranslation("settings");
const [open, setOpen] = useState(defaultOpen);
const Chevron = open ? ChevronDown : ChevronRight;
return (
<div className="pet-chat-info border-b border-border/60 px-3 py-2">
<button
type="button"
className="flex w-full items-center gap-1.5 rounded-lg text-left"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-label={t("sagi.chat.status")}
title={t("sagi.chat.status")}
>
<StatChip
icon={Radio}
label={t("sagi.panel.statLive")}
value={status.liveCount}
tone={status.liveCount > 0 ? "accent" : "muted"}
/>
<StatChip
icon={Hourglass}
label={t("sagi.panel.statWaiting")}
value={status.waitingCount}
tone={status.waitingCount > 0 ? "amber" : "muted"}
/>
<StatChip
icon={AlertTriangle}
label={t("sagi.panel.statErrored")}
value={status.errorCount}
tone={status.errorCount > 0 ? "red" : "muted"}
/>
<span
className={`ml-auto inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
status.connected
? "bg-status-success/15 text-status-success"
: "bg-status-danger/15 text-status-danger"
}`}
>
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${
status.connected ? "bg-status-success" : "bg-status-danger"
}`}
aria-hidden
/>
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
</span>
<Chevron size={13} className="shrink-0 text-fg-muted" aria-hidden />
</button>
{open && (
<div className="mt-2 max-h-48 overflow-y-auto">
{/* the sessions that need the user, by name */}
{attention.length > 0 && (
<div className="mb-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">
{t("sagi.panel.attention")}
</div>
<ul className="space-y-1">
{attention.slice(0, ATTENTION_SHOWN).map((item) => (
<li key={item.id}>
<button
className="group flex w-full items-center gap-2 rounded-lg bg-surface-1 px-2 py-1.5 text-left text-xs transition-colors hover:bg-surface-4"
onClick={() => onOpenSession(item)}
title={item.cwd ?? item.id}
>
{item.kind === "error" ? (
<AlertTriangle
size={13}
className="shrink-0 text-status-danger"
aria-hidden
/>
) : (
<Hourglass size={13} className="shrink-0 text-status-warning" aria-hidden />
)}
<span className="min-w-0 flex-1 truncate text-fg-secondary group-hover:text-fg-primary">
{subjectLabel(item)}
</span>
<span className="shrink-0 text-[10px] text-fg-muted">
{item.kind === "error"
? t("sagi.panel.attentionError")
: t("sagi.panel.attentionWaiting")}
</span>
<ChevronRight size={12} className="shrink-0 text-fg-muted" aria-hidden />
</button>
</li>
))}
</ul>
{attention.length > ATTENTION_SHOWN && (
<button
className="mt-1 text-[11px] text-accent hover:underline"
onClick={() => onNavigate("/sessions")}
>
{t("sagi.panel.attentionMore", { count: attention.length - ATTENTION_SHOWN })}
</button>
)}
</div>
)}
<div className="grid grid-cols-2 gap-1.5">
<ActionButton
icon={muted ? BellOff : Bell}
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
onClick={onToggleMute}
/>
<ActionButton
icon={Trash2}
label={t("sagi.panel.clearAlerts")}
disabled={status.errorCount === 0}
onClick={onClearAlerts}
/>
</div>
</div>
)}
</div>
);
}
interface Tone {
wrap: string;
value: string;
icon: string;
}
const TONE_MUTED: Tone = {
wrap: "border-border bg-surface-1",
value: "text-fg-secondary",
icon: "text-fg-muted",
};
const TONES: Record<string, Tone> = {
accent: { wrap: "border-accent/30 bg-accent/10", value: "text-fg-primary", icon: "text-accent" },
amber: {
wrap: "border-status-warning/30 bg-status-warning/10",
value: "text-status-warning",
icon: "text-status-warning",
},
red: {
wrap: "border-status-danger/30 bg-status-danger/10",
value: "text-status-danger",
icon: "text-status-danger",
},
muted: TONE_MUTED,
};
/** One count, compact: icon, number, label on one line. */
function StatChip({
icon: Icon,
label,
value,
tone,
}: {
icon: LucideIcon;
label: string;
value: number;
tone: string;
}) {
const t = TONES[tone] ?? TONE_MUTED;
return (
<span className={`inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 ${t.wrap}`}>
<Icon size={11} className={t.icon} aria-hidden />
<span className={`text-xs font-semibold tabular-nums ${t.value}`}>{value}</span>
<span className="text-[9px] uppercase tracking-wider text-fg-muted">{label}</span>
</span>
);
}
function ActionButton({
icon: Icon,
label,
onClick,
disabled,
}: {
icon: LucideIcon;
label: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
className="flex w-full items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
onClick={onClick}
disabled={disabled}
>
<Icon size={14} className="shrink-0" />
<span className="truncate">{label}</span>
</button>
);
}
-392
View File
@@ -1,392 +0,0 @@
/**
* @file SagiPanel.tsx
* @description Expanded Sagi panel: a live status strip (live / waiting /
* errored stat chips + connection state), quick navigation actions, and a
* local "Ask" box. Pure presentational - all data and the ask/navigation
* behavior are injected by the container.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
*
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
*
* ## Remote data & SSH
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Internal dependencies
* - `./brain`
*
* ## Public surface
* - `SagiPanel` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
* - Server contract changes require `npm run test:server` and OpenAPI sync.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **SagiPanel**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* ----------------------------------------------------------------------------- */
import { useState, type FormEvent, type ReactNode } from "react";
import {
Bell,
BellOff,
Trash2,
X,
Send,
AlertTriangle,
Hourglass,
Radio,
Heart,
ChevronRight,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
import { PET_NAME_MAX } from "./prefs";
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
const ATTENTION_SHOWN = 4;
interface SagiPanelProps {
/** The name the pet goes by now. */
name: string;
/** The pet's own name, which an empty nickname falls back to. */
ownName: string;
/** The user's nickname for the pet, "" when none. */
nickname: string;
onRename: (name: string) => void;
onPat: () => void;
/** Errored, then waiting sessions, named. */
attention: ReadonlyArray<AttentionItem>;
onOpenSession: (s: SagiSubject) => void;
status: SagiStatus;
muted: boolean;
onToggleMute: () => void;
onClearAlerts: () => void;
onNavigate: (route: string) => void;
/** Returns an answer to display, or null when the query was handed off. */
onAsk: (query: string) => string | null;
onClose: () => void;
}
export function SagiPanel({
name,
ownName,
nickname,
onRename,
onPat,
attention,
onOpenSession,
status,
muted,
onToggleMute,
onClearAlerts,
onNavigate,
onAsk,
onClose,
}: SagiPanelProps) {
const { t } = useTranslation("settings");
const [query, setQuery] = useState("");
const [answer, setAnswer] = useState<string | null>(null);
// The name being typed, or null while not renaming.
const [renaming, setRenaming] = useState<string | null>(null);
const saveName = () => {
if (renaming === null) return;
onRename(renaming);
setRenaming(null);
};
const submit = (e: FormEvent) => {
e.preventDefault();
const result = onAsk(query);
setAnswer(result); // null means it handed off (container navigates/closes)
setQuery("");
};
return (
<div
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl backdrop-blur-md animate-slide-up"
role="dialog"
aria-label={t("sagi.panel.label", { name })}
>
{/* header */}
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<span className="text-base leading-none" aria-hidden>
🐾
</span>
{renaming === null ? (
<button
type="button"
className="min-w-0 truncate rounded px-0.5 text-sm font-semibold text-fg-primary hover:bg-surface-4"
onClick={() => setRenaming(nickname)}
aria-label={t("sagi.panel.rename", { name })}
title={t("sagi.panel.rename", { name })}
>
{name}
</button>
) : (
<input
autoFocus
className="w-28 rounded border border-accent bg-surface-1 px-1 py-0.5 text-sm text-fg-primary focus:outline-none"
value={renaming}
maxLength={PET_NAME_MAX}
placeholder={ownName}
aria-label={t("sagi.panel.renameLabel")}
title={t("sagi.panel.renameHint")}
onChange={(e) => setRenaming(e.target.value)}
onBlur={saveName}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
saveName();
} else if (e.key === "Escape") {
// Esc cancels the rename only, not the whole panel.
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
setRenaming(null);
}
}}
/>
)}
<span
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
status.connected
? "bg-status-success/15 text-status-success"
: "bg-status-danger/15 text-status-danger"
}`}
>
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${
status.connected ? "bg-status-success" : "bg-status-danger"
}`}
aria-hidden
/>
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
</span>
</div>
<div className="flex shrink-0 items-center">
<button
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
onClick={onPat}
aria-label={t("sagi.panel.pat", { name })}
title={t("sagi.panel.pat", { name })}
>
<Heart size={15} />
</button>
<button
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
onClick={onClose}
aria-label={t("sagi.panel.close", { name })}
>
<X size={15} />
</button>
</div>
</div>
<div className="p-3">
{/* status stat chips */}
<div className="mb-3 grid grid-cols-3 gap-1.5">
<StatChip
icon={Radio}
label={t("sagi.panel.statLive")}
value={status.liveCount}
tone={status.liveCount > 0 ? "accent" : "muted"}
/>
<StatChip
icon={Hourglass}
label={t("sagi.panel.statWaiting")}
value={status.waitingCount}
tone={status.waitingCount > 0 ? "amber" : "muted"}
/>
<StatChip
icon={AlertTriangle}
label={t("sagi.panel.statErrored")}
value={status.errorCount}
tone={status.errorCount > 0 ? "red" : "muted"}
/>
</div>
{/* the sessions that need the user, by name */}
{attention.length > 0 && (
<div className="mb-3">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">
{t("sagi.panel.attention")}
</div>
<ul className="space-y-1">
{attention.slice(0, ATTENTION_SHOWN).map((item) => (
<li key={item.id}>
<button
className="group flex w-full items-center gap-2 rounded-lg bg-surface-1 px-2 py-1.5 text-left text-xs transition-colors hover:bg-surface-4"
onClick={() => onOpenSession(item)}
title={item.cwd ?? item.id}
>
{item.kind === "error" ? (
<AlertTriangle
size={13}
className="shrink-0 text-status-danger"
aria-hidden
/>
) : (
<Hourglass size={13} className="shrink-0 text-status-warning" aria-hidden />
)}
<span className="min-w-0 flex-1 truncate text-fg-secondary group-hover:text-fg-primary">
{subjectLabel(item)}
</span>
<span className="shrink-0 text-[10px] text-fg-muted">
{item.kind === "error"
? t("sagi.panel.attentionError")
: t("sagi.panel.attentionWaiting")}
</span>
<ChevronRight size={12} className="shrink-0 text-fg-muted" aria-hidden />
</button>
</li>
))}
</ul>
{attention.length > ATTENTION_SHOWN && (
<button
className="mt-1 text-[11px] text-accent hover:underline"
onClick={() => onNavigate("/sessions")}
>
{t("sagi.panel.attentionMore", { count: attention.length - ATTENTION_SHOWN })}
</button>
)}
</div>
)}
{/* quick actions */}
<div className="mb-3 grid grid-cols-2 gap-1.5">
<ActionButton
icon={muted ? BellOff : Bell}
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
onClick={onToggleMute}
/>
<ActionButton
icon={Trash2}
label={t("sagi.panel.clearAlerts")}
disabled={status.errorCount === 0}
onClick={onClearAlerts}
/>
</div>
{/* ask */}
<form onSubmit={submit} className="flex items-center gap-1.5">
<input
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-fg-secondary placeholder-fg-muted transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
placeholder={t("sagi.panel.askPlaceholder", { name })}
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label={t("sagi.panel.ask", { name })}
/>
<button
type="submit"
className="btn-primary justify-center px-2.5 py-2"
aria-label={t("sagi.panel.send")}
>
<Send size={14} />
</button>
</form>
{answer && (
<p className="mt-2 rounded-lg bg-surface-1/70 px-2.5 py-2 text-xs leading-relaxed text-fg-secondary">
{answer}
</p>
)}
</div>
</div>
);
}
interface Tone {
wrap: string;
value: string;
icon: string;
}
const TONE_MUTED: Tone = {
wrap: "border-border bg-surface-1",
value: "text-fg-secondary",
icon: "text-fg-muted",
};
const TONES: Record<string, Tone> = {
accent: { wrap: "border-accent/30 bg-accent/10", value: "text-fg-primary", icon: "text-accent" },
amber: {
wrap: "border-status-warning/30 bg-status-warning/10",
value: "text-status-warning",
icon: "text-status-warning",
},
red: {
wrap: "border-status-danger/30 bg-status-danger/10",
value: "text-status-danger",
icon: "text-status-danger",
},
muted: TONE_MUTED,
};
function StatChip({
icon: Icon,
label,
value,
tone,
}: {
icon: LucideIcon;
label: string;
value: number;
tone: string;
}): ReactNode {
const t = TONES[tone] ?? TONE_MUTED;
return (
<div className={`flex flex-col items-center gap-0.5 rounded-xl border py-1.5 ${t.wrap}`}>
<Icon size={13} className={t.icon} aria-hidden />
<span className={`text-base font-semibold leading-none tabular-nums ${t.value}`}>
{value}
</span>
<span className="text-[9px] uppercase tracking-wider text-fg-muted">{label}</span>
</div>
);
}
function ActionButton({
icon: Icon,
label,
onClick,
disabled,
}: {
icon: LucideIcon;
label: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
className="flex w-full items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
onClick={onClick}
disabled={disabled}
>
<Icon size={14} className="shrink-0" />
<span className="truncate">{label}</span>
</button>
);
}
@@ -1,6 +1,6 @@
/** /**
* @file Sagi.test.tsx * @file Sagi.test.tsx
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B panel toggle, and accessibility attributes. * @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B chat toggle (the chat window is the pet's whole UI: status strip, local answers, New chat stopping the old session), and accessibility attributes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -18,6 +18,7 @@ import { useSagiPosition } from "../useSagiPosition";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { Sagi } from "../Sagi"; import { Sagi } from "../Sagi";
import { eventBus } from "../../../lib/eventBus"; import { eventBus } from "../../../lib/eventBus";
import { api } from "../../../lib/api";
import type { WSMessage, Session } from "../../../lib/types"; import type { WSMessage, Session } from "../../../lib/types";
function renderSagi() { function renderSagi() {
@@ -54,13 +55,13 @@ describe("Sagi widget", () => {
expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument(); expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument();
}); });
it("opens the panel on click and answers a local status question", () => { it("opens the chat on click and answers a local status question there", () => {
renderSagi(); renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /sagi companion/i }); const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
expect(panel).toBeInTheDocument(); expect(panel).toBeInTheDocument();
const input = within(panel).getByLabelText(/ask sagi/i); const input = within(panel).getByLabelText(/ask sagi anything/i);
fireEvent.change(input, { target: { value: "status" } }); fireEvent.change(input, { target: { value: "status" } });
fireEvent.submit(input.closest("form")!); fireEvent.submit(input.closest("form")!);
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument(); expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
@@ -88,7 +89,7 @@ describe("Sagi widget", () => {
expect(within(btn).getByText("1")).toBeInTheDocument(); expect(within(btn).getByText("1")).toBeInTheDocument();
}); });
it("names the errored session in its bubble and in the panel", () => { it("names the errored session in its bubble and in the chat", () => {
renderSagi(); renderSagi();
act(() => { act(() => {
eventBus.publish({ eventBus.publish({
@@ -101,7 +102,7 @@ describe("Sagi widget", () => {
expect(within(bubble).getByText("Fix login")).toBeInTheDocument(); expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument(); expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /sagi companion/i }); const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
expect(within(panel).getByText("Needs you")).toBeInTheDocument(); expect(within(panel).getByText("Needs you")).toBeInTheDocument();
expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument(); expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
}); });
@@ -121,43 +122,54 @@ describe("Sagi widget", () => {
expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]); expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]);
}); });
it("renames the pet from the panel header", () => { it("renames the pet from the chat header", () => {
renderSagi(); renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i })); fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
const input = screen.getByLabelText("Pet name"); const input = screen.getByLabelText("Pet name");
fireEvent.change(input, { target: { value: "Mochi" } }); fireEvent.change(input, { target: { value: "Mochi" } });
fireEvent.keyDown(input, { key: "Enter" }); fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByRole("dialog", { name: /mochi companion/i })).toBeInTheDocument(); expect(screen.getByRole("dialog", { name: /chat with mochi/i })).toBeInTheDocument();
expect(screen.getByLabelText(/ask mochi/i)).toBeInTheDocument(); expect(screen.getByLabelText(/ask mochi anything/i)).toBeInTheDocument();
}); });
it("hands a question it cannot answer to the chat window", () => { it("hands a question it cannot answer to the AI", () => {
localStorage.setItem("agenthub.defaultTool", "claude"); localStorage.setItem("agenthub.defaultTool", "claude");
const ensure = vi.spyOn(api.lanes, "ensure").mockReturnValue(new Promise(() => {}));
renderSagi(); renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const input = screen.getByLabelText(/ask sagi/i);
fireEvent.change(input, { target: { value: "write me a haiku" } });
fireEvent.submit(input.closest("form")!);
const chat = screen.getByRole("dialog", { name: /chat with sagi/i }); const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument(); const input = within(chat).getByLabelText(/ask sagi anything/i);
expect(screen.queryByRole("dialog", { name: /sagi companion/i })).not.toBeInTheDocument();
});
it("has no Chat button — Ask opens the chat — and Back returns to the panel", () => {
localStorage.setItem("agenthub.defaultTool", "claude");
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
expect(screen.queryByRole("button", { name: /chat with sagi/i })).not.toBeInTheDocument();
const input = screen.getByLabelText(/ask sagi/i);
fireEvent.change(input, { target: { value: "write me a haiku" } }); fireEvent.change(input, { target: { value: "write me a haiku" } });
fireEvent.submit(input.closest("form")!); fireEvent.submit(input.closest("form")!);
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument(); expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Back" })); expect(ensure).toHaveBeenCalledWith({ title: "Sagi" });
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument(); ensure.mockRestore();
}); });
it("a pat shows its reaction on the pet even while the panel is open", () => { it("reopens the remembered chat, and New chat stops its session", async () => {
localStorage.setItem(
"agenthub.pet.chat",
JSON.stringify({ laneId: 7, runId: "agenthub-lane-7-1", tool: "claude" })
);
const get = vi
.spyOn(api.run, "get")
.mockReturnValue(new Promise(() => {}) as ReturnType<typeof api.run.get>);
const kill = vi.spyOn(api.run, "kill").mockResolvedValue({ ok: true });
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
expect(screen.getByRole("button", { name: /open in workspace/i })).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /new chat/i }));
});
expect(kill).toHaveBeenCalledWith("agenthub-lane-7-1", { archive: true });
expect(localStorage.getItem("agenthub.pet.chat")).toBeNull();
expect(screen.queryByRole("button", { name: /open in workspace/i })).not.toBeInTheDocument();
get.mockRestore();
kill.mockRestore();
});
it("a pat shows its reaction on the pet even while the chat is open", () => {
renderSagi(); renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i })); fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
@@ -175,16 +187,16 @@ describe("Sagi widget", () => {
expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull(); expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull();
}); });
it("reflects the live count in the panel status", () => { it("reflects the live count in the chat status", () => {
renderSagi(); renderSagi();
act(() => { act(() => {
eventBus.publish(sessionMsg("a", "active")); eventBus.publish(sessionMsg("a", "active"));
eventBus.publish(sessionMsg("b", "active")); eventBus.publish(sessionMsg("b", "active"));
}); });
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
const panel = screen.getByRole("dialog", { name: /sagi companion/i }); const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
// The "live" stat chip shows value 2 next to its label. // The "live" stat chip shows value 2 next to its label.
const liveChip = within(panel).getByText("live").closest("div")!; const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
expect(within(liveChip).getByText("2")).toBeInTheDocument(); expect(within(liveChip).getByText("2")).toBeInTheDocument();
}); });
@@ -194,7 +206,7 @@ describe("Sagi widget", () => {
expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument();
}); });
it("a tap (no movement) still opens the panel", () => { it("a tap (no movement) still opens the chat", () => {
renderSagi(); renderSagi();
const btn = screen.getByRole("button", { name: /open sagi companion/i }); const btn = screen.getByRole("button", { name: /open sagi companion/i });
act(() => { act(() => {
@@ -202,7 +214,7 @@ describe("Sagi widget", () => {
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 }); fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
}); });
fireEvent.click(btn); fireEvent.click(btn);
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument(); expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
}); });
it("dragging snaps to an edge, persists position, and does not open the panel", () => { it("dragging snaps to an edge, persists position, and does not open the panel", () => {
@@ -231,7 +243,7 @@ describe("Sagi widget", () => {
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 }); fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
}); });
fireEvent.click(btn); fireEvent.click(btn);
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument(); expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
// No position was persisted because no real drag happened. // No position was persisted because no real drag happened.
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull(); expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
}); });
+4 -5
View File
@@ -37,18 +37,14 @@
"chooseDesc": "Featured pets come from codex-pets.net and are downloaded into this install the first time you pick one. Your choice and size are saved to your account.", "chooseDesc": "Featured pets come from codex-pets.net and are downloaded into this install the first time you pick one. Your choice and size are saved to your account.",
"uploadHint": "An image (PNG, JPEG, GIF, WebP — no SVG) is shown still; a .codex-pet.zip (pet.json + a 1536×1872 spritesheet.webp) is animated by mood.", "uploadHint": "An image (PNG, JPEG, GIF, WebP — no SVG) is shown still; a .codex-pet.zip (pet.json + a 1536×1872 spritesheet.webp) is animated by mood.",
"panel": { "panel": {
"label": "{{name}} companion",
"live": "Live", "live": "Live",
"offline": "Offline", "offline": "Offline",
"close": "Close {{name}}",
"statLive": "live", "statLive": "live",
"statWaiting": "waiting", "statWaiting": "waiting",
"statErrored": "errored", "statErrored": "errored",
"mute": "Mute", "mute": "Mute",
"unmute": "Unmute", "unmute": "Unmute",
"clearAlerts": "Clear alerts", "clearAlerts": "Clear alerts",
"askPlaceholder": "Ask {{name}}… (e.g. any errors?)",
"ask": "Ask {{name}}",
"send": "Send", "send": "Send",
"rename": "Click to rename {{name}}", "rename": "Click to rename {{name}}",
"renameLabel": "Pet name", "renameLabel": "Pet name",
@@ -217,9 +213,12 @@
}, },
"chat": { "chat": {
"label": "Chat with {{name}}", "label": "Chat with {{name}}",
"back": "Back",
"close": "Close {{name}}", "close": "Close {{name}}",
"new": "New chat", "new": "New chat",
"status": "Status — click to show the sessions that need you",
"endTitle": "End this chat?",
"endBody": "It is still writing a reply. A new chat stops this session.",
"endConfirm": "Stop and start new",
"openWorkspace": "Open in workspace", "openWorkspace": "Open in workspace",
"typing": "typing…", "typing": "typing…",
"placeholder": "Ask {{name}} anything…", "placeholder": "Ask {{name}} anything…",
+4 -5
View File
@@ -37,18 +37,14 @@
"chooseDesc": "Pet nổi bật lấy từ codex-pets.net và được tải về máy ở lần đầu bạn chọn. Lựa chọn và kích thước được lưu vào tài khoản của bạn.", "chooseDesc": "Pet nổi bật lấy từ codex-pets.net và được tải về máy ở lần đầu bạn chọn. Lựa chọn và kích thước được lưu vào tài khoản của bạn.",
"uploadHint": "Ảnh (PNG, JPEG, GIF, WebP — không nhận SVG) hiển thị tĩnh; file .codex-pet.zip (pet.json + spritesheet.webp 1536×1872) chuyển động theo tâm trạng.", "uploadHint": "Ảnh (PNG, JPEG, GIF, WebP — không nhận SVG) hiển thị tĩnh; file .codex-pet.zip (pet.json + spritesheet.webp 1536×1872) chuyển động theo tâm trạng.",
"panel": { "panel": {
"label": "Trợ lý {{name}}",
"live": "Trực tuyến", "live": "Trực tuyến",
"offline": "Ngoại tuyến", "offline": "Ngoại tuyến",
"close": "Đóng {{name}}",
"statLive": "đang chạy", "statLive": "đang chạy",
"statWaiting": "đang chờ", "statWaiting": "đang chờ",
"statErrored": "lỗi", "statErrored": "lỗi",
"mute": "Tắt tiếng", "mute": "Tắt tiếng",
"unmute": "Bật tiếng", "unmute": "Bật tiếng",
"clearAlerts": "Xoá cảnh báo", "clearAlerts": "Xoá cảnh báo",
"askPlaceholder": "Hỏi {{name}}… (vd: có lỗi nào không?)",
"ask": "Hỏi {{name}}",
"send": "Gửi", "send": "Gửi",
"rename": "Bấm để đổi tên {{name}}", "rename": "Bấm để đổi tên {{name}}",
"renameLabel": "Tên pet", "renameLabel": "Tên pet",
@@ -217,9 +213,12 @@
}, },
"chat": { "chat": {
"label": "Trò chuyện với {{name}}", "label": "Trò chuyện với {{name}}",
"back": "Quay lại",
"close": "Đóng {{name}}", "close": "Đóng {{name}}",
"new": "Cuộc trò chuyện mới", "new": "Cuộc trò chuyện mới",
"status": "Trạng thái — bấm để xem các phiên cần bạn",
"endTitle": "Kết thúc chat này?",
"endBody": "Nó vẫn đang trả lời. Chat mới sẽ dừng phiên này.",
"endConfirm": "Dừng và bắt đầu mới",
"openWorkspace": "Mở trong workspace", "openWorkspace": "Mở trong workspace",
"typing": "đang trả lời…", "typing": "đang trả lời…",
"placeholder": "Hỏi {{name}} bất cứ điều gì…", "placeholder": "Hỏi {{name}} bất cứ điều gì…",
+53 -1
View File
@@ -207,6 +207,35 @@ describe("panePaste", () => {
expect(m.options.map((o) => o.n)).toEqual([1, 2]); expect(m.options.map((o) => o.n)).toEqual([1, 2]);
}); });
it("reads Claude's unnumbered trust prompt and picks by arrows", () => {
vi.useFakeTimers();
const m = parsePaneMenu(TRUST)!;
expect(m.options.map((o) => o.label)).toEqual(["No, exit", "Yes, I trust this folder"]);
expect(m.options.every((o) => o.n === null)).toBe(true);
expect(m.cursor).toBe(0);
expect(m.question.split("\n")[0]).toBe("Accessing workspace:");
expect(m.question).toContain("Security guide");
const keys: string[] = [];
const off = registerPane(
"agenthub-lane-12",
() => {},
() => true,
(k) => keys.push(k)
);
expect(chooseRunOption("agenthub-lane-12", m, 1)).toBe(true);
vi.runAllTimers();
expect(keys).toEqual(["\x1b[B", "\r"]);
off();
vi.useRealTimers();
});
it("finds no unnumbered menu in Claude's input box or a wrapped prompt above it", () => {
expect(parsePaneMenu(CLAUDE)).toBeNull();
expect(
parsePaneMenu(["❯ a long prompt that", " wrapped onto two rows", "", RULE, "❯\u00a0", RULE])
).toBeNull();
});
it("finds no menu in a reply's numbered list or a typed `1.`", () => { it("finds no menu in a reply's numbered list or a typed `1.`", () => {
// No cursor on the list. // No cursor on the list.
expect(parsePaneMenu(["● Steps:", " 1. one", " 2. two", "", RULE, "❯ ", RULE])).toBeNull(); expect(parsePaneMenu(["● Steps:", " 1. one", " 2. two", "", RULE, "❯ ", RULE])).toBeNull();
@@ -313,12 +342,35 @@ const CODEX_APPROVAL = [
" enteresc", " enteresc",
]; ];
// Claude Code's folder-trust prompt: no numbers, its footer dim (left out).
const TRUST = [
RULE,
" Accessing workspace:",
"",
" /home/me",
"",
" Quick safety check: Is this a project you created or one you trust?",
"",
" Security guide",
"",
" ❯ No, exit",
" Yes, I trust this folder",
"",
"",
];
describe("MENU_CURSOR_ROW", () => { describe("MENU_CURSOR_ROW", () => {
// The chat's 700 ms poll reads the screen cell by cell only when a row // The chat's 700 ms poll reads the screen cell by cell only when a row
// matches it, so every menu parsePaneMenu finds must have such a row. // matches it, so every menu parsePaneMenu finds must have such a row.
it("matches a row of every menu, and no row of an idle prompt", () => { it("matches a row of every menu, and no row of an idle prompt", () => {
const has = (rows: string[]) => rows.some((r) => MENU_CURSOR_ROW.test(r)); const has = (rows: string[]) => rows.some((r) => MENU_CURSOR_ROW.test(r));
for (const menu of [ASK, MULTI, PERMISSION, CODEX_APPROVAL]) { for (const menu of [
ASK,
MULTI,
PERMISSION,
CODEX_APPROVAL,
[...TRUST, " Enter to confirm · Esc to cancel"],
]) {
expect(parsePaneMenu(menu)).not.toBeNull(); expect(parsePaneMenu(menu)).not.toBeNull();
expect(has(menu)).toBe(true); expect(has(menu)).toBe(true);
} }
+38 -3
View File
@@ -176,8 +176,9 @@ export type PaneMenu = {
}; };
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/; const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/;
/** A row a menu's cursor sits on (a numbered option or Submit): a menu has one. */ /** A row only a menu shows: its cursor on a numbered option or Submit, or the
export const MENU_CURSOR_ROW = /^ *[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)/; * "Enter to confirm" footer of Claude's unnumbered menus (the trust prompt). */
export const MENU_CURSOR_ROW = /^ *(?:[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)|Enter to confirm)/;
const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/; const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/;
const RULE_ROW = /^ *[─╌]{3,} *$/; const RULE_ROW = /^ *[─╌]{3,} *$/;
const CHECK = /^\[([ ✔])\] *(.*)$/; const CHECK = /^\[([ ✔])\] *(.*)$/;
@@ -224,7 +225,8 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
} }
} }
const numbered = options.filter((o) => o.n !== null); const numbered = options.filter((o) => o.n !== null);
if (top < 0 || cursor < 0 || numbered.length < 2 || RULE_ROW.test(r[top - 1] ?? "")) return null; if (top < 0 || cursor < 0 || numbered.length < 2 || RULE_ROW.test(r[top - 1] ?? ""))
return parseBareMenu(r.slice(0, end + 1));
if (numbered.some((o, i) => o.n !== i + 1)) return null; if (numbered.some((o, i) => o.n !== i + 1)) return null;
// The question: the block of text right above the first option. // The question: the block of text right above the first option.
let q = top - 1; let q = top - 1;
@@ -241,6 +243,39 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
}; };
} }
const BARE_CURSOR = /^( {0,4})❯[ \u00a0]+(\S.*)$/;
/** Claude's UNNUMBERED menu at the bottom of `r` (the folder-trust prompt:
* `❯ No, exit` over ` Yes, I trust this folder`), or null. Its rows share the
* cursor row's text column, and nothing under them is a rule — Claude's input
* box draws one above and below its `❯` row, so neither the box nor a prompt
* in the history above it reads as a menu. Picked by arrows and Enter. */
function parseBareMenu(r: string[]): PaneMenu | null {
let at = -1;
for (let i = r.length - 1; i >= 0 && at < 0; i--) if (BARE_CURSOR.test(r[i] ?? "")) at = i;
if (at < 0 || RULE_ROW.test(r[at - 1] ?? "") || r.slice(at + 1).some((l) => RULE_ROW.test(l)))
return null;
const m = BARE_CURSOR.exec(r[at]!)!;
const indent = " ".repeat((m[1] ?? "").length + 2);
const sibling = (l = "") => l.startsWith(indent) && /\S/.test(l[indent.length] ?? " ");
let top = at;
while (top > 0 && sibling(r[top - 1])) top--;
let last = at;
while (last < r.length - 1 && sibling(r[last + 1])) last++;
if (last === top) return null;
const options = r.slice(top, last + 1).map((l, i) => ({
n: null,
label: top + i === at ? (m[2] ?? "").trim() : l.trim(),
hint: "",
checked: null,
}));
// The question: the paragraphs above, up to the dialog's top rule.
const lines: string[] = [];
for (let q = top - 1; q >= 0 && !RULE_ROW.test(r[q]!) && lines.length < 8; q--)
if (r[q]) lines.unshift(r[q]!.trim());
return { question: lines.join("\n"), options, cursor: at - top, multi: false, typeAt: -1 };
}
/** The "Type something" row. Text typed into it replaces its label, and it is /** The "Type something" row. Text typed into it replaces its label, and it is
* still the last numbered row above "Chat about this". */ * still the last numbered row above "Chat about this". */
function typeRow(options: PaneMenuOption[]): number { function typeRow(options: PaneMenuOption[]): number {
+1 -1
View File
@@ -2954,7 +2954,7 @@ POST /api/pets/featured/:id Download a FEATURED pet from codex-pets.ne
DELETE /api/pets/:id Delete a pet (its folder under the data dir) DELETE /api/pets/:id Delete a pet (its folder under the data dir)
``` ```
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn (with its display name, which the mascot goes by) and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); a name the user types by clicking it in the mascot's panel is `agenthub.pet.names` (`{ [petId]: name }`, empty = the pet's own); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes. The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn (with its display name, which the mascot goes by) and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); a name the user types by clicking it in the mascot's chat window header is `agenthub.pet.names` (`{ [petId]: name }`, empty = the pet's own); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames — or a v2 atlas (`spriteVersionNumber: 2`, 1536×2288, 11 rows) whose rows 9–10 add 16 gaze poses, 22.5° apart clockwise from straight up. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, a live session at rest → review, idle/asleep → idle), and on an event it plays a moment over the mood (a new session → waving, a finish or a pat → jumping, a new subagent → review, a session waiting on you → waiting, an error → failed); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves, and a v2 pet at idle turns toward the pointer (back to idle 2 s after the pointer rests). Frames play at Codex's own timings (`codex-rs/tui/src/pets/model.rs`: e.g. running 120 ms a frame, its last 220 ms); idle plays at the drawn pace (280/110/110/140/140/320 ms), a sleeping pet at Codex's 6× slower one. Waving, jumping, failed and review are moments — they play three times and settle into idle; running, waiting and the run rows loop. Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly one of those sizes (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script. A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames — or a v2 atlas (`spriteVersionNumber: 2`, 1536×2288, 11 rows) whose rows 9–10 add 16 gaze poses, 22.5° apart clockwise from straight up. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, a live session at rest → review, idle/asleep → idle), and on an event it plays a moment over the mood (a new session → waving, a finish or a pat → jumping, a new subagent → review, a session waiting on you → waiting, an error → failed); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves, and a v2 pet at idle turns toward the pointer (back to idle 2 s after the pointer rests). Frames play at Codex's own timings (`codex-rs/tui/src/pets/model.rs`: e.g. running 120 ms a frame, its last 220 ms); idle plays at the drawn pace (280/110/110/140/140/320 ms), a sleeping pet at Codex's 6× slower one. Waving, jumping, failed and review are moments — they play three times and settle into idle; running, waiting and the run rows loop. Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly one of those sizes (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
+24 -3
View File
@@ -3,14 +3,20 @@
* @file Detached helper spawned by `server/lib/self-restart.js`. Polls the * @file Detached helper spawned by `server/lib/self-restart.js`. Polls the
* old dashboard PID until it exits (server/index.js's own SIGTERM handler * old dashboard PID until it exits (server/index.js's own SIGTERM handler
* has a 5s force-exit backstop, so this never waits forever), then starts a * has a 5s force-exit backstop, so this never waits forever), then starts a
* fresh server from the same entry point and exits itself. * fresh server from the same entry point and exits itself. The new server's
* stdout/stderr are appended to the log file it is given (the checkout's
* `data/agenthub-server.log`, the one `agenthub start` writes), so a crash
* after an update still leaves Node's stack trace behind.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
require("../server/lib/windows-hide").install(); // before child_process is read require("../server/lib/windows-hide").install(); // before child_process is read
const fs = require("fs");
const path = require("path");
const { spawn } = require("child_process"); const { spawn } = require("child_process");
const { rotateIfLarge } = require("./log-rotate");
const [, , pidArg, entry] = process.argv; const [, , pidArg, entry, logArg] = process.argv;
const pid = parseInt(pidArg, 10); const pid = parseInt(pidArg, 10);
// ponytail: fixed poll cadence/cap, not configurable — this script has one caller. // ponytail: fixed poll cadence/cap, not configurable — this script has one caller.
@@ -26,10 +32,25 @@ function isAlive(p) {
} }
} }
/** Append fd for the new server's output, or "ignore" when there is none. */
function openLog(file) {
if (!file) return "ignore";
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
rotateIfLarge(file);
const fd = fs.openSync(file, "a");
fs.writeSync(fd, `[${new Date().toISOString()}] restart-helper: old server ${pid} gone, starting ${entry}\n`);
return fd;
} catch {
return "ignore"; // a log must never stop the restart
}
}
function restart() { function restart() {
const out = openLog(logArg);
const child = spawn(process.execPath, [entry], { const child = spawn(process.execPath, [entry], {
detached: true, detached: true,
stdio: "ignore", stdio: ["ignore", out, out],
env: process.env, env: process.env,
}); });
child.unref(); child.unref();
+39
View File
@@ -204,6 +204,45 @@ describe("pty-attach", () => {
assert.deepEqual(JSON.parse(a.__sent.at(-1).data), { type: "lead" }); assert.deepEqual(JSON.parse(a.__sent.at(-1).data), { type: "lead" });
}); });
it("survives a stopped pane: pipe errors, typing and resizes after the attach exited", () => {
// Windows node-pty re-throws a read-pipe error nobody listens for, and its
// write pipe has no listener at all: Stop (kill-session) ended the attach
// with EPIPE and the uncaught error killed the whole dashboard.
const ptys = [];
ptyAttach.__setSpawnImpl(() => {
const p = makeFakePty();
p.on = (evt, fn) => p.__emitter.on(evt, fn);
p._agent = { inSocket: new EventEmitter() };
p.resize = () => {
if (p.__gone) throw new Error("Cannot resize a pty that has already exited");
};
p.write = () => {
if (p.__gone) throw new Error("write EPIPE");
};
ptys.push(p);
return p;
});
const a = makeFakeWs();
const b = makeFakeWs();
a.close = b.close = () => {};
ptyAttach.attach(a, "agenthub-lane-1", { cols: 80, rows: 24 });
ptyAttach.attach(b, "agenthub-lane-1", { cols: 80, rows: 24 });
const epipe = Object.assign(new Error("read EPIPE"), { code: "EPIPE" });
assert.doesNotThrow(() => ptys[0].__emitter.emit("error", epipe));
assert.doesNotThrow(() => ptys[0]._agent.inSocket.emit("error", epipe));
ptys[0].__gone = true;
ptys[0].__emitter.emit("exit", { exitCode: 1 }); // a's close event not yet in
const msg = (ws, data, binary) => ws.__emitter.emit("message", Buffer.from(data), { binary });
assert.doesNotThrow(() =>
msg(b, JSON.stringify({ type: "resize", cols: 90, rows: 30, drive: true }), false)
);
assert.doesNotThrow(() => msg(a, "x", true));
ptys[1].__gone = true; // exited, onExit still queued
assert.doesNotThrow(() => msg(b, "y", true));
});
it("kills the PTY attach process when the WS connection closes", () => { it("kills the PTY attach process when the WS connection closes", () => {
const ws = makeFakeWs(); const ws = makeFakeWs();
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24 }); ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24 });
+30 -2
View File
@@ -3,12 +3,17 @@
* @description After an update, a server our service unit runs is restarted * @description After an update, a server our service unit runs is restarted
* by the unit, never by a detached replacement: v2.7.2 on the S3 system unit * by the unit, never by a detached replacement: v2.7.2 on the S3 system unit
* left the replacement holding the port while `Restart=always` started the * left the replacement holding the port while `Restart=always` started the
* unit every 3 s, each start exiting "already running". * unit every 3 s, each start exiting "already running". Without a unit, the
* detached helper's replacement server logs to `data/agenthub-server.log`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
const { describe, it } = require("node:test"); const { describe, it } = require("node:test");
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { scheduleRestart, owningUnit } = require("../lib/self-restart"); const { scheduleRestart, owningUnit } = require("../lib/self-restart");
function deps(unit, restartAnswer = "restarted") { function deps(unit, restartAnswer = "restarted") {
@@ -23,8 +28,9 @@ function deps(unit, restartAnswer = "restarted") {
}, },
}), }),
kill: () => calls.kill++, kill: () => calls.kill++,
spawnHelper: () => { spawnHelper: (_exe, args) => {
calls.spawn++; calls.spawn++;
calls.helperArgs = args;
return { unref() {} }; return { unref() {} };
}, },
}; };
@@ -84,5 +90,27 @@ describe("self-restart", () => {
assert.equal(scheduleRestart("/r", d), "helper"); assert.equal(scheduleRestart("/r", d), "helper");
assert.equal(d.calls.spawn, 1); assert.equal(d.calls.spawn, 1);
assert.equal(d.calls.kill, 1); assert.equal(d.calls.kill, 1);
// The new server logs where `agenthub start` does — never into nothing.
assert.equal(d.calls.helperArgs.at(-1), path.join("/r", "data", "agenthub-server.log"));
});
it("restart-helper appends the new server's stdout and stderr to the log", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "restart-helper-"));
const entry = path.join(dir, "entry.js");
const log = path.join(dir, "data", "agenthub-server.log");
fs.writeFileSync(entry, 'console.log("up"); throw new Error("boom");\n');
const helper = path.join(__dirname, "..", "..", "scripts", "restart-helper.js");
// pid 0x7fffffff is never alive, so the helper restarts at once.
execFileSync(process.execPath, [helper, "2147483647", entry, log]);
const deadline = Date.now() + 5000;
let text = "";
while (Date.now() < deadline && !/boom/.test(text)) {
await new Promise((r) => setTimeout(r, 50));
text = fs.existsSync(log) ? fs.readFileSync(log, "utf8") : "";
}
fs.rmSync(dir, { recursive: true, force: true });
assert.match(text, /restart-helper: old server 2147483647 gone/);
assert.match(text, /^up$/m);
assert.match(text, /Error: boom/);
}); });
}); });
+43 -4
View File
@@ -84,17 +84,49 @@ const tell = (v, msg) => {
} }
}; };
// node-pty throws from resize() once its process has exited ("Cannot resize a
// pty that has already exited" on Windows), and a viewer's attach can exit
// before its socket's close event takes it out of the group — a throw here,
// inside a WebSocket message handler, would take the whole server down.
const resize = (v, cols, rows) => {
if (v.exited) return;
try {
v.pty.resize(cols, rows);
} catch {
/* attach already gone — its exit/close handlers clean up */
}
};
/** `v` sizes the window now: every other attach is held at its size and told. */ /** `v` sizes the window now: every other attach is held at its size and told. */
function drive(group, v) { function drive(group, v) {
group.driver = v; group.driver = v;
v.pty.resize(v.cols, v.rows); resize(v, v.cols, v.rows);
for (const o of group.viewers) { for (const o of group.viewers) {
if (o === v) continue; if (o === v) continue;
o.pty.resize(v.cols, v.rows); resize(o, v.cols, v.rows);
tell(o, { type: "size", cols: v.cols, rows: v.rows }); tell(o, { type: "size", cols: v.cols, rows: v.rows });
} }
} }
/**
* Windows node-pty re-throws any read-pipe error that is not EIO unless a
* second `error` listener exists, and its write pipe has no listener at all —
* so the EPIPE/ECONNRESET a pane's attach raises when the session is stopped
* (Stop, kill-session) became an uncaught exception that killed the dashboard.
* The attach's exit is reported by `onExit`; the error itself carries nothing.
*/
function swallowPipeErrors(pty) {
const ignore = () => {};
try {
pty.on?.("error", ignore);
} catch {
/* no event emitter on this pty — nothing to guard */
}
// Private on node-pty's Windows agent; absent elsewhere and in tests.
const input = pty._agent?.inSocket;
if (input && typeof input.on === "function") input.on("error", ignore);
}
/** /**
* Attach `ws` to the tmux session `runId`. Spawns one PTY-backed * Attach `ws` to the tmux session `runId`. Spawns one PTY-backed
* `tmux attach-session -t <runId>` per call. * `tmux attach-session -t <runId>` per call.
@@ -132,7 +164,8 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
// newer one VS Code uses for the same reason. Ignored elsewhere. // newer one VS Code uses for the same reason. Ignored elsewhere.
...(platform === "win32" && { useConptyDll: true }), ...(platform === "win32" && { useConptyDll: true }),
}); });
const me = { ws, pty, cols: cols || 80, rows: rows || 24 }; swallowPipeErrors(pty);
const me = { ws, pty, cols: cols || 80, rows: rows || 24, exited: false };
group.viewers.add(me); group.viewers.add(me);
if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows }); if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows });
else group.driver = me; else group.driver = me;
@@ -179,16 +212,22 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
} }
}; };
function write(text) { function write(text) {
if (me.exited) return; // the attach is gone: nothing reads this pipe
const wait = holdUntil - Date.now(); const wait = holdUntil - Date.now();
if (held.length || wait > 0) { if (held.length || wait > 0) {
if (held.push(text) === 1) setTimeout(flush, Math.max(0, wait)); if (held.push(text) === 1) setTimeout(flush, Math.max(0, wait));
return; return;
} }
pty.write(text); try {
pty.write(text);
} catch {
return; // exited between the check and the write — onExit follows
}
if (holdsPaste && text.includes("\x1b[201~")) holdUntil = Date.now() + PASTE_HOLD_MS; if (holdsPaste && text.includes("\x1b[201~")) holdUntil = Date.now() + PASTE_HOLD_MS;
} }
pty.onExit(({ exitCode }) => { pty.onExit(({ exitCode }) => {
me.exited = true;
try { try {
ws.send(JSON.stringify({ type: "exit", code: exitCode }), { binary: false }); ws.send(JSON.stringify({ type: "exit", code: exitCode }), { binary: false });
} catch { } catch {
+4 -1
View File
@@ -77,7 +77,10 @@ function scheduleRestart(root = path.join(__dirname, "..", ".."), deps = {}) {
} }
const helperPath = path.join(root, "scripts", "restart-helper.js"); const helperPath = path.join(root, "scripts", "restart-helper.js");
const entry = process.argv[1] || path.join(root, "server", "index.js"); const entry = process.argv[1] || path.join(root, "server", "index.js");
const helper = spawnHelper(process.execPath, [helperPath, String(process.pid), entry], { // The same log `agenthub start` writes: without it every server started by
// an update ran with no stdout/stderr at all, and a crash left no trace.
const logFile = path.join(root, "data", "agenthub-server.log");
const helper = spawnHelper(process.execPath, [helperPath, String(process.pid), entry, logFile], {
cwd: root, cwd: root,
detached: true, detached: true,
stdio: "ignore", stdio: "ignore",