-
{name}
+ {title ??
{name}
}
+ {headerActions}
{chat && (
remember(null)}
- disabled={!chat || starting}
+ onClick={() => void newChat()}
+ disabled={(!chat && !local.length) || starting}
>
@@ -372,6 +421,8 @@ export function PetChat({
{empty && (
@@ -475,10 +526,9 @@ export function PetChat({
e.preventDefault();
submit();
} else if (e.key === "Escape") {
- // Esc leaves the chat for the panel, not the whole mascot.
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
- onBack();
+ onClose();
}
}}
/>
diff --git a/client/src/components/Sagi/Sagi.tsx b/client/src/components/Sagi/Sagi.tsx
index eea91143..4ed41e8b 100644
--- a/client/src/components/Sagi/Sagi.tsx
+++ b/client/src/components/Sagi/Sagi.tsx
@@ -5,17 +5,20 @@
* 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
* 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
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
* useSagiPosition (a v2 Codex pet also looks toward the pointer), and the
* bubble/panel render in a self-clamping flyout so
* 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 "Sagi" workspace carrying it as the first prompt, then opens that
- * workspace's session view (`/workspace?lane=
&run=`) — no new LLM
- * backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
+ * The "do the job" path: an unmatched question is the chat's — it starts a
+ * session of the default AI tool in the "Sagi" workspace; a default tool the
+ * chat cannot read back starts there and its session view opens
+ * (`/workspace?lane=&run=`) — 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).
* @author Nguyễn Ngọc Trí Vĩ
*/
@@ -39,7 +42,7 @@
* ## Internal dependencies
* - `./SagiAvatar`
* - `./SpeechBubble`
- * - `./SagiPanel`
+ * - `./SagiInfo`
* - `./useSagiBrain`
* - `./useSagiPosition`
* - `./intents`
@@ -83,7 +86,7 @@ import { SagiAvatar } from "./SagiAvatar";
import { PetSprite } from "./PetSprite";
import { PetMenu } from "./PetMenu";
import { SpeechBubble } from "./SpeechBubble";
-import { SagiPanel } from "./SagiPanel";
+import { PetNameButton, SagiInfo } from "./SagiInfo";
import { useSagiBrain } from "./useSagiBrain";
import { useSagiPosition } from "./useSagiPosition";
import { useSagiEnabled } from "./SagiMount";
@@ -94,7 +97,8 @@ import { api } from "../../lib/api";
import { readDefaultTool } from "../../lib/sessionDefaults";
import { startInFreeTabDetailed } from "../lanes/startSession";
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
// 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.
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
const [size, setSize] = useState(() => sagiPrefs.getSize());
+ // The chat window (PetChat.tsx): the pet's whole UI.
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(null);
// The right-click pet menu (`PetMenu.tsx`).
const [menu, setMenu] = useState(false);
const [hover, setHover] = useState(false);
@@ -296,7 +298,6 @@ function SagiBody() {
setOpen((v) => !v);
} else if (e.key === "Escape") {
setOpen(false);
- setChatOpen(false);
}
};
window.addEventListener("keydown", onKey);
@@ -346,7 +347,6 @@ function SagiBody() {
navigate(workspaceHref(s, runs) ?? `/sessions/${encodeURIComponent(s.id)}`);
brain.dismissBubble();
setOpen(false);
- setChatOpen(false);
};
api.run
.list()
@@ -356,21 +356,17 @@ function SagiBody() {
[brain, navigate]
);
- const onAsk = useCallback(
- (query: string): string | null => {
+ // Asked by the chat before any session runs.
+ const onLocal = useCallback(
+ (query: string): PetLocalReply => {
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
if (result.kind === "answer") {
if (result.moment) brain.playMoment(result.moment);
- return 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;
+ return { answer: result.text };
}
+ // 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
// in the "Sagi" workspace and open its pane there, as before.
const prompt = result.prompt;
@@ -392,7 +388,7 @@ function SagiBody() {
brain.setThinking(false);
}
})();
- return null;
+ return { handled: true };
},
[brain, navigate, t, name]
);
@@ -431,46 +427,49 @@ function SagiBody() {
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 && (
-
- 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)}
- />
-
- )}
-
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
- it 1:1), and kept mounted: unmounting would detach its terminal and
- lose a half-typed message. */}
- {chatOpen && (
+ it 1:1). Closing unmounts it; its chat is remembered, so reopening
+ re-attaches the same session. */}
+ {open && (
setChatPrompt(null)}
- onBack={() => {
- setChatOpen(false);
- setOpen(true);
- }}
- onClose={() => setChatOpen(false)}
+ title={
+ sagiPrefs.setNickname(pet.id, next)}
+ />
+ }
+ headerActions={
+
+
+
+ }
+ info={
+ 0}
+ />
+ }
+ onLocal={onLocal}
+ onClose={() => setOpen(false)}
onOpenWorkspace={(laneId, runId) => {
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
- setChatOpen(false);
+ setOpen(false);
}}
onBusy={onBusy}
/>
@@ -491,7 +490,7 @@ function SagiBody() {
)}
- {!open && !menu && !chatOpen && brain.bubble && (
+ {!open && !menu && brain.bubble && (
!v);
+ setOpen((v) => !v);
}}
// Right-click: the pet menu (hide, size, choose, upload, import).
onContextMenu={(e) => {
e.preventDefault();
setOpen(false);
- setChatOpen(false);
setMenu(true);
}}
- aria-label={open || chatOpen ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
- aria-expanded={open || chatOpen}
+ aria-label={open ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
+ aria-expanded={open}
title={t("sagi.avatarTitle", { name })}
>
{pet.id === "sagi" || !("kind" in pet) ? (
diff --git a/client/src/components/Sagi/SagiInfo.tsx b/client/src/components/Sagi/SagiInfo.tsx
new file mode 100644
index 00000000..68dff124
--- /dev/null
+++ b/client/src/components/Sagi/SagiInfo.tsx
@@ -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ĩ
+ */
+
+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(null);
+ const save = () => {
+ if (renaming === null) return;
+ onRename(renaming);
+ setRenaming(null);
+ };
+ return renaming === null ? (
+ setRenaming(nickname)}
+ aria-label={t("sagi.panel.rename", { name })}
+ title={t("sagi.panel.rename", { name })}
+ >
+ {name}
+
+ ) : (
+ 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;
+ 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 (
+
+
setOpen((v) => !v)}
+ aria-expanded={open}
+ aria-label={t("sagi.chat.status")}
+ title={t("sagi.chat.status")}
+ >
+ 0 ? "accent" : "muted"}
+ />
+ 0 ? "amber" : "muted"}
+ />
+ 0 ? "red" : "muted"}
+ />
+
+
+ {status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
+
+
+
+
+ {open && (
+
+ {/* the sessions that need the user, by name */}
+ {attention.length > 0 && (
+
+
+ {t("sagi.panel.attention")}
+
+
+ {attention.slice(0, ATTENTION_SHOWN).map((item) => (
+
+ onOpenSession(item)}
+ title={item.cwd ?? item.id}
+ >
+ {item.kind === "error" ? (
+
+ ) : (
+
+ )}
+
+ {subjectLabel(item)}
+
+
+ {item.kind === "error"
+ ? t("sagi.panel.attentionError")
+ : t("sagi.panel.attentionWaiting")}
+
+
+
+
+ ))}
+
+ {attention.length > ATTENTION_SHOWN && (
+
onNavigate("/sessions")}
+ >
+ {t("sagi.panel.attentionMore", { count: attention.length - ATTENTION_SHOWN })}
+
+ )}
+
+ )}
+
+
+
+ )}
+
+ );
+}
+
+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 = {
+ 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 (
+
+
+ {value}
+ {label}
+
+ );
+}
+
+function ActionButton({
+ icon: Icon,
+ label,
+ onClick,
+ disabled,
+}: {
+ icon: LucideIcon;
+ label: string;
+ onClick: () => void;
+ disabled?: boolean;
+}) {
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/client/src/components/Sagi/SagiPanel.tsx b/client/src/components/Sagi/SagiPanel.tsx
deleted file mode 100644
index a9679495..00000000
--- a/client/src/components/Sagi/SagiPanel.tsx
+++ /dev/null
@@ -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ĩ
- */
-/* =============================================================================
- * 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;
- 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(null);
- // The name being typed, or null while not renaming.
- const [renaming, setRenaming] = useState(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 (
-
- {/* header */}
-
-
-
- 🐾
-
- {renaming === null ? (
- setRenaming(nickname)}
- aria-label={t("sagi.panel.rename", { name })}
- title={t("sagi.panel.rename", { name })}
- >
- {name}
-
- ) : (
- 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);
- }
- }}
- />
- )}
-
-
- {status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* status stat chips */}
-
- 0 ? "accent" : "muted"}
- />
- 0 ? "amber" : "muted"}
- />
- 0 ? "red" : "muted"}
- />
-
-
- {/* the sessions that need the user, by name */}
- {attention.length > 0 && (
-
-
- {t("sagi.panel.attention")}
-
-
- {attention.slice(0, ATTENTION_SHOWN).map((item) => (
-
- onOpenSession(item)}
- title={item.cwd ?? item.id}
- >
- {item.kind === "error" ? (
-
- ) : (
-
- )}
-
- {subjectLabel(item)}
-
-
- {item.kind === "error"
- ? t("sagi.panel.attentionError")
- : t("sagi.panel.attentionWaiting")}
-
-
-
-
- ))}
-
- {attention.length > ATTENTION_SHOWN && (
-
onNavigate("/sessions")}
- >
- {t("sagi.panel.attentionMore", { count: attention.length - ATTENTION_SHOWN })}
-
- )}
-
- )}
-
- {/* quick actions */}
-
-
- {/* ask */}
-
- {answer && (
-
- {answer}
-
- )}
-
-
- );
-}
-
-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 = {
- 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 (
-
-
-
- {value}
-
- {label}
-
- );
-}
-
-function ActionButton({
- icon: Icon,
- label,
- onClick,
- disabled,
-}: {
- icon: LucideIcon;
- label: string;
- onClick: () => void;
- disabled?: boolean;
-}) {
- return (
-
-
- {label}
-
- );
-}
diff --git a/client/src/components/Sagi/__tests__/Sagi.test.tsx b/client/src/components/Sagi/__tests__/Sagi.test.tsx
index b642d583..196aabb0 100644
--- a/client/src/components/Sagi/__tests__/Sagi.test.tsx
+++ b/client/src/components/Sagi/__tests__/Sagi.test.tsx
@@ -1,6 +1,6 @@
/**
* @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ĩ
*/
@@ -18,6 +18,7 @@ import { useSagiPosition } from "../useSagiPosition";
import { MemoryRouter } from "react-router-dom";
import { Sagi } from "../Sagi";
import { eventBus } from "../../../lib/eventBus";
+import { api } from "../../../lib/api";
import type { WSMessage, Session } from "../../../lib/types";
function renderSagi() {
@@ -54,13 +55,13 @@ describe("Sagi widget", () => {
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();
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();
- const input = within(panel).getByLabelText(/ask sagi/i);
+ const input = within(panel).getByLabelText(/ask sagi anything/i);
fireEvent.change(input, { target: { value: "status" } });
fireEvent.submit(input.closest("form")!);
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
@@ -88,7 +89,7 @@ describe("Sagi widget", () => {
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();
act(() => {
eventBus.publish({
@@ -101,7 +102,7 @@ describe("Sagi widget", () => {
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
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).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
});
@@ -121,43 +122,54 @@ describe("Sagi widget", () => {
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();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
const input = screen.getByLabelText("Pet name");
fireEvent.change(input, { target: { value: "Mochi" } });
fireEvent.keyDown(input, { key: "Enter" });
- expect(screen.getByRole("dialog", { name: /mochi companion/i })).toBeInTheDocument();
- expect(screen.getByLabelText(/ask mochi/i)).toBeInTheDocument();
+ expect(screen.getByRole("dialog", { name: /chat with 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");
+ const ensure = vi.spyOn(api.lanes, "ensure").mockReturnValue(new Promise(() => {}));
renderSagi();
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 });
- expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
- 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);
+ const input = within(chat).getByLabelText(/ask sagi anything/i);
fireEvent.change(input, { target: { value: "write me a haiku" } });
fireEvent.submit(input.closest("form")!);
- expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
- fireEvent.click(screen.getByRole("button", { name: "Back" }));
- expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
+ expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
+ expect(ensure).toHaveBeenCalledWith({ title: "Sagi" });
+ 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);
+ 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();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/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();
});
- it("reflects the live count in the panel status", () => {
+ it("reflects the live count in the chat status", () => {
renderSagi();
act(() => {
eventBus.publish(sessionMsg("a", "active"));
eventBus.publish(sessionMsg("b", "active"));
});
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.
- 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();
});
@@ -194,7 +206,7 @@ describe("Sagi widget", () => {
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();
const btn = screen.getByRole("button", { name: /open sagi companion/i });
act(() => {
@@ -202,7 +214,7 @@ describe("Sagi widget", () => {
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
});
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", () => {
@@ -231,7 +243,7 @@ describe("Sagi widget", () => {
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
});
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.
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
});
diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json
index 476fa4fe..9e40469f 100644
--- a/client/src/i18n/locales/en/settings.json
+++ b/client/src/i18n/locales/en/settings.json
@@ -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.",
"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": {
- "label": "{{name}} companion",
"live": "Live",
"offline": "Offline",
- "close": "Close {{name}}",
"statLive": "live",
"statWaiting": "waiting",
"statErrored": "errored",
"mute": "Mute",
"unmute": "Unmute",
"clearAlerts": "Clear alerts",
- "askPlaceholder": "Ask {{name}}… (e.g. any errors?)",
- "ask": "Ask {{name}}",
"send": "Send",
"rename": "Click to rename {{name}}",
"renameLabel": "Pet name",
@@ -217,9 +213,12 @@
},
"chat": {
"label": "Chat with {{name}}",
- "back": "Back",
"close": "Close {{name}}",
"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",
"typing": "typing…",
"placeholder": "Ask {{name}} anything…",
diff --git a/client/src/i18n/locales/vi/settings.json b/client/src/i18n/locales/vi/settings.json
index fe387658..b760b7eb 100644
--- a/client/src/i18n/locales/vi/settings.json
+++ b/client/src/i18n/locales/vi/settings.json
@@ -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.",
"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": {
- "label": "Trợ lý {{name}}",
"live": "Trực tuyến",
"offline": "Ngoại tuyến",
- "close": "Đóng {{name}}",
"statLive": "đang chạy",
"statWaiting": "đang chờ",
"statErrored": "lỗi",
"mute": "Tắt tiếng",
"unmute": "Bật tiếng",
"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",
"rename": "Bấm để đổi tên {{name}}",
"renameLabel": "Tên pet",
@@ -217,9 +213,12 @@
},
"chat": {
"label": "Trò chuyện với {{name}}",
- "back": "Quay lại",
"close": "Đóng {{name}}",
"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",
"typing": "đang trả lời…",
"placeholder": "Hỏi {{name}} bất cứ điều gì…",
diff --git a/client/src/lib/__tests__/panePaste.test.ts b/client/src/lib/__tests__/panePaste.test.ts
index e2495762..61b78cc1 100644
--- a/client/src/lib/__tests__/panePaste.test.ts
+++ b/client/src/lib/__tests__/panePaste.test.ts
@@ -207,6 +207,35 @@ describe("panePaste", () => {
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.`", () => {
// No cursor on the list.
expect(parsePaneMenu(["● Steps:", " 1. one", " 2. two", "", RULE, "❯ ", RULE])).toBeNull();
@@ -313,12 +342,35 @@ const CODEX_APPROVAL = [
" 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", () => {
// 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.
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));
- 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(has(menu)).toBe(true);
}
diff --git a/client/src/lib/panePaste.ts b/client/src/lib/panePaste.ts
index 39c81249..ee99c7be 100644
--- a/client/src/lib/panePaste.ts
+++ b/client/src/lib/panePaste.ts
@@ -176,8 +176,9 @@ export type PaneMenu = {
};
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. */
-export const MENU_CURSOR_ROW = /^ *[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)/;
+/** A row only a menu shows: its cursor on a numbered option or Submit, or the
+ * "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 RULE_ROW = /^ *[─╌]{3,} *$/;
const CHECK = /^\[([ ✔])\] *(.*)$/;
@@ -224,7 +225,8 @@ export function parsePaneMenu(rows: string[]): PaneMenu | 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;
// The question: the block of text right above the first option.
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
* still the last numbered row above "Chat about this". */
function typeRow(options: PaneMenuOption[]): number {
diff --git a/docs/API.md b/docs/API.md
index a3219ed4..d2c84baf 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -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)
```
-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 `/pets//` 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 `/pets//` 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.
diff --git a/scripts/restart-helper.js b/scripts/restart-helper.js
index 362739c9..d44dce32 100644
--- a/scripts/restart-helper.js
+++ b/scripts/restart-helper.js
@@ -3,14 +3,20 @@
* @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
* 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ĩ
*/
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 { rotateIfLarge } = require("./log-rotate");
-const [, , pidArg, entry] = process.argv;
+const [, , pidArg, entry, logArg] = process.argv;
const pid = parseInt(pidArg, 10);
// 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() {
+ const out = openLog(logArg);
const child = spawn(process.execPath, [entry], {
detached: true,
- stdio: "ignore",
+ stdio: ["ignore", out, out],
env: process.env,
});
child.unref();
diff --git a/server/__tests__/pty-attach.test.js b/server/__tests__/pty-attach.test.js
index 113012fe..ed013bae 100644
--- a/server/__tests__/pty-attach.test.js
+++ b/server/__tests__/pty-attach.test.js
@@ -204,6 +204,45 @@ describe("pty-attach", () => {
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", () => {
const ws = makeFakeWs();
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24 });
diff --git a/server/__tests__/self-restart.test.js b/server/__tests__/self-restart.test.js
index dab2c4dd..a9c26426 100644
--- a/server/__tests__/self-restart.test.js
+++ b/server/__tests__/self-restart.test.js
@@ -3,12 +3,17 @@
* @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
* 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ĩ
*/
const { describe, it } = require("node:test");
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");
function deps(unit, restartAnswer = "restarted") {
@@ -23,8 +28,9 @@ function deps(unit, restartAnswer = "restarted") {
},
}),
kill: () => calls.kill++,
- spawnHelper: () => {
+ spawnHelper: (_exe, args) => {
calls.spawn++;
+ calls.helperArgs = args;
return { unref() {} };
},
};
@@ -84,5 +90,27 @@ describe("self-restart", () => {
assert.equal(scheduleRestart("/r", d), "helper");
assert.equal(d.calls.spawn, 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/);
});
});
diff --git a/server/lib/pty-attach.js b/server/lib/pty-attach.js
index 89018e17..1b307591 100644
--- a/server/lib/pty-attach.js
+++ b/server/lib/pty-attach.js
@@ -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. */
function drive(group, v) {
group.driver = v;
- v.pty.resize(v.cols, v.rows);
+ resize(v, v.cols, v.rows);
for (const o of group.viewers) {
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 });
}
}
+/**
+ * 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
* `tmux attach-session -t ` 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.
...(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);
if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows });
else group.driver = me;
@@ -179,16 +212,22 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
}
};
function write(text) {
+ if (me.exited) return; // the attach is gone: nothing reads this pipe
const wait = holdUntil - Date.now();
if (held.length || wait > 0) {
if (held.push(text) === 1) setTimeout(flush, Math.max(0, wait));
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;
}
pty.onExit(({ exitCode }) => {
+ me.exited = true;
try {
ws.send(JSON.stringify({ type: "exit", code: exitCode }), { binary: false });
} catch {
diff --git a/server/lib/self-restart.js b/server/lib/self-restart.js
index 62411ef4..726e3c3c 100644
--- a/server/lib/self-restart.js
+++ b/server/lib/self-restart.js
@@ -77,7 +77,10 @@ function scheduleRestart(root = path.join(__dirname, "..", ".."), deps = {}) {
}
const helperPath = path.join(root, "scripts", "restart-helper.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,
detached: true,
stdio: "ignore",