Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 750d74a2af | |||
| 8a6dc6a29e | |||
| c042519bc9 | |||
| 383b9ae231 | |||
| aee4fc190e |
+14
-3
@@ -4166,14 +4166,25 @@ async function cmdUsers(sub, positional, flags) {
|
||||
case "list":
|
||||
table(
|
||||
["id", "username", "role", "state"],
|
||||
auth.listUsers().map((u) => [u.id, u.username, u.role, u.disabled ? "disabled" : "active"])
|
||||
auth
|
||||
.listUsers()
|
||||
.map((u) => [
|
||||
u.id,
|
||||
u.username,
|
||||
u.role,
|
||||
u.disabled ? "disabled" : u.mustChangePassword ? "active (new password due)" : "active",
|
||||
])
|
||||
);
|
||||
return;
|
||||
case "add": {
|
||||
need();
|
||||
const role = typeof flags.role === "string" ? flags.role : "developer";
|
||||
const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role);
|
||||
console.log(`${c.green("✔")} ${user.username} added (${user.role})`);
|
||||
const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role, {
|
||||
mustChange: true,
|
||||
});
|
||||
console.log(
|
||||
`${c.green("✔")} ${user.username} added (${user.role}) — they choose their own password at first login`
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "passwd":
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @file AuthCard.tsx
|
||||
* @description The card the signed-out screens share (the login page, the
|
||||
* first-login password change in `FirstPasswordPage.tsx`): a centred form
|
||||
* with the theme's accent glow and grid behind the brand logo, a title and a
|
||||
* subtitle. While it shows, the OS's light/dark applies (`applySystemTheme`),
|
||||
* since no account's theme has been chosen for it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useLayoutEffect, type FormEvent, type ReactNode } from "react";
|
||||
import { BRAND_LOGO } from "../lib/brand";
|
||||
import { applySystemTheme } from "../hooks/useTheme";
|
||||
|
||||
export function AuthCard({
|
||||
title,
|
||||
subtitle,
|
||||
onSubmit,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onSubmit: (e: FormEvent) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
// Follow the OS, live.
|
||||
useLayoutEffect(() => {
|
||||
applySystemTheme();
|
||||
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
mq?.addEventListener?.("change", applySystemTheme);
|
||||
return () => mq?.removeEventListener?.("change", applySystemTheme);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="card relative w-full max-w-md overflow-hidden px-6 pb-8 pt-12 sm:px-10"
|
||||
>
|
||||
{/* Accent glow and a grid fading out from the top, drawn from the
|
||||
theme's own accent so it holds in every theme. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-56"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, rgb(var(--accent) / 0.12) 1px, transparent 1px), linear-gradient(to bottom, rgb(var(--accent) / 0.12) 1px, transparent 1px), radial-gradient(ellipse at top, rgb(var(--accent) / 0.16), transparent 70%)",
|
||||
backgroundSize: "48px 48px, 48px 48px, 100% 100%",
|
||||
backgroundPosition: "center top",
|
||||
maskImage: "radial-gradient(ellipse 70% 100% at top, black 30%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative space-y-5">
|
||||
<div className="text-center">
|
||||
<img src={BRAND_LOGO} alt="SmartGift" className="block h-auto w-full" />
|
||||
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-fg-primary">{title}</h1>
|
||||
<p className="mt-2 text-sm text-fg-secondary">{subtitle}</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,9 @@
|
||||
* `usePermissions()` says what that user's role may do, so pages hide the rest.
|
||||
* Once an account is known (local mode is one), its own UI preferences are
|
||||
* loaded into this browser (`lib/userPrefs.ts`).
|
||||
* The login page follows the OS's light/dark (`applySystemTheme`).
|
||||
* The login page follows the OS's light/dark (`AuthCard`). An account whose
|
||||
* password an admin gave (`mustChangePassword`) sees only
|
||||
* `FirstPasswordPage` until it picks its own.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -20,15 +22,14 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { BRAND_LOGO } from "../lib/brand";
|
||||
import { applySystemTheme } from "../hooks/useTheme";
|
||||
import { AuthCard } from "./AuthCard";
|
||||
import { FirstPasswordPage } from "./FirstPasswordPage";
|
||||
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
||||
import { hydrate } from "../lib/userPrefs";
|
||||
|
||||
@@ -165,6 +166,17 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
await api.auth.logout().catch(() => {});
|
||||
setState({ mode: "server", user: null });
|
||||
};
|
||||
// A password an admin gave: nothing else until the user picks their own
|
||||
// (the server refuses the rest anyway — EPWCHANGE).
|
||||
if (state.mode === "server" && state.user?.mustChangePassword) {
|
||||
return (
|
||||
<FirstPasswordPage
|
||||
user={state.user}
|
||||
onDone={() => updateUser({ mustChangePassword: false })}
|
||||
onLogout={() => void logout()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AuthContext.Provider value={{ state, logout, updateUser }}>{children}</AuthContext.Provider>
|
||||
);
|
||||
@@ -178,14 +190,6 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Nobody is signed in, so no account's theme applies: follow the OS, live.
|
||||
useLayoutEffect(() => {
|
||||
applySystemTheme();
|
||||
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
mq?.addEventListener?.("change", applySystemTheme);
|
||||
return () => mq?.removeEventListener?.("change", applySystemTheme);
|
||||
}, []);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
@@ -201,86 +205,60 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Nobody is signed in, so no account's theme applies: AuthCard follows the OS.
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="card relative w-full max-w-md overflow-hidden px-6 pb-8 pt-12 sm:px-10"
|
||||
>
|
||||
{/* Accent glow and a grid fading out from the top, drawn from the
|
||||
theme's own accent so it holds in every theme. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-56"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, rgb(var(--accent) / 0.12) 1px, transparent 1px), linear-gradient(to bottom, rgb(var(--accent) / 0.12) 1px, transparent 1px), radial-gradient(ellipse at top, rgb(var(--accent) / 0.16), transparent 70%)",
|
||||
backgroundSize: "48px 48px, 48px 48px, 100% 100%",
|
||||
backgroundPosition: "center top",
|
||||
maskImage: "radial-gradient(ellipse 70% 100% at top, black 30%, transparent 100%)",
|
||||
}}
|
||||
<AuthCard title={t("auth.title")} subtitle={t("auth.subtitle")} onSubmit={submit}>
|
||||
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<span>{t("auth.username")}</span>
|
||||
<input
|
||||
className="input h-11 w-full px-4"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
placeholder={t("auth.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<div className="relative space-y-5">
|
||||
<div className="text-center">
|
||||
<img src={BRAND_LOGO} alt="SmartGift" className="block h-auto w-full" />
|
||||
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-fg-primary">
|
||||
{t("auth.title")}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-fg-secondary">{t("auth.subtitle")}</p>
|
||||
</div>
|
||||
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<span>{t("auth.username")}</span>
|
||||
<input
|
||||
className="input h-11 w-full px-4"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
placeholder={t("auth.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<label htmlFor="login-password">{t("auth.password")}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="login-password"
|
||||
className="input h-11 w-full pl-4 pr-11"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? t("auth.hidePassword") : t("auth.showPassword")}
|
||||
aria-pressed={showPassword}
|
||||
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{/* Inverted fill (text colour behind the surface colour): black in a
|
||||
light theme, light in a dark one, like the reference design. */}
|
||||
</label>
|
||||
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<label htmlFor="login-password">{t("auth.password")}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="login-password"
|
||||
className="input h-11 w-full pl-4 pr-11"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={busy}
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? t("auth.hidePassword") : t("auth.showPassword")}
|
||||
aria-pressed={showPassword}
|
||||
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{busy ? t("auth.submitting") : t("auth.submit")}
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
<p className="text-center text-sm text-fg-secondary">{t("auth.noAccount")}</p>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{/* Inverted fill (text colour behind the surface colour): black in a
|
||||
light theme, light in a dark one, like the reference design. */}
|
||||
<button
|
||||
type="submit"
|
||||
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? t("auth.submitting") : t("auth.submit")}
|
||||
</button>
|
||||
<p className="text-center text-sm text-fg-secondary">{t("auth.noAccount")}</p>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @file FirstPasswordPage.tsx
|
||||
* @description The screen an account signed in with a password an admin GAVE
|
||||
* (created or reset on the Users page — `mustChangePassword`) sees instead of
|
||||
* the app: the current password, a new one that must pass every rule (the
|
||||
* `PasswordRules` checklist ticks them as they are typed) and the same again,
|
||||
* sent to `POST /api/me/password`. On success the app opens; "Sign out"
|
||||
* leaves. The server refuses every other request meanwhile (EPWCHANGE).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type AuthUser } from "../lib/api";
|
||||
import { passwordProblems } from "../lib/passwordRules";
|
||||
import { AuthCard } from "./AuthCard";
|
||||
import { PasswordRules } from "./users/PasswordRules";
|
||||
|
||||
export function FirstPasswordPage({
|
||||
user,
|
||||
onDone,
|
||||
onLogout,
|
||||
}: {
|
||||
user: AuthUser;
|
||||
onDone: () => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [again, setAgain] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mismatch = again.length > 0 && again !== next;
|
||||
const strong = passwordProblems(next, user.username).length === 0;
|
||||
const ready = current.length > 0 && strong && again === next && !busy;
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!ready) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.me.changePassword(current, next);
|
||||
onDone();
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
setError(
|
||||
code && t(`users:errors.${code}`, { defaultValue: "" })
|
||||
? t(`users:errors.${code}`)
|
||||
: (err as Error).message
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const field = (
|
||||
label: string,
|
||||
value: string,
|
||||
set: (v: string) => void,
|
||||
autoComplete: string,
|
||||
autoFocus = false
|
||||
) => (
|
||||
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<span>{label}</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input h-11 w-full px-4"
|
||||
autoComplete={autoComplete}
|
||||
autoFocus={autoFocus}
|
||||
required
|
||||
value={value}
|
||||
onChange={(e) => set(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthCard
|
||||
title={t("auth.mustChangeTitle")}
|
||||
subtitle={t("auth.mustChangeSubtitle")}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{field(t("auth.currentPassword"), current, setCurrent, "current-password", true)}
|
||||
{field(t("auth.newPassword"), next, setNext, "new-password")}
|
||||
<PasswordRules password={next} username={user.username} />
|
||||
{field(t("auth.confirmPassword"), again, setAgain, "new-password")}
|
||||
{(mismatch || error) && (
|
||||
<p role="alert" className="text-sm text-status-danger">
|
||||
{error ?? t("auth.mismatch")}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!ready}
|
||||
>
|
||||
{busy ? t("auth.saving") : t("auth.mustChangeSubmit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full text-center text-sm text-fg-secondary hover:text-fg-primary"
|
||||
onClick={onLogout}
|
||||
>
|
||||
{t("auth.logout")}
|
||||
</button>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
chooseRunOption,
|
||||
interruptRun,
|
||||
readRunMenu,
|
||||
stepRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
} from "../../lib/panePaste";
|
||||
@@ -505,6 +506,9 @@ export function PetChat({
|
||||
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onStep={(dir) => {
|
||||
if (!stepRunMenu(chat.runId, dir)) setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (interruptRun(chat.runId)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* `useWorkLanes` mirror the server's roles: an admin changes everything, an
|
||||
* analyst reads everything but works only in their own workspaces, a
|
||||
* developer only ever sees (and so changes) their own. The login page wears the
|
||||
* OS's light/dark; a sign-in swaps in the account's saved appearance.
|
||||
* OS's light/dark; a sign-in swaps in the account's saved appearance. A
|
||||
* password an admin gave shows only the "choose your own password" page,
|
||||
* whose button waits for every rule, until the change succeeds.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -18,6 +20,7 @@ import "../../i18n";
|
||||
const me = vi.fn();
|
||||
const login = vi.fn();
|
||||
const prefs = vi.fn();
|
||||
const changePassword = vi.fn();
|
||||
const LANES = [
|
||||
{ id: 1, owner_id: 5 },
|
||||
{ id: 2, owner_id: 6 },
|
||||
@@ -27,7 +30,11 @@ vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
|
||||
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
|
||||
me: { prefs: () => prefs(), savePrefs: () => Promise.resolve({ prefs: {} }) },
|
||||
me: {
|
||||
prefs: () => prefs(),
|
||||
savePrefs: () => Promise.resolve({ prefs: {} }),
|
||||
changePassword: (c: string, n: string) => changePassword(c, n),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -56,10 +63,30 @@ beforeEach(() => {
|
||||
me.mockReset();
|
||||
login.mockReset();
|
||||
prefs.mockReset().mockResolvedValue({ prefs: {} });
|
||||
changePassword.mockReset().mockResolvedValue({ ok: true });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("AuthGate", () => {
|
||||
it("makes an account with an admin-given password choose its own first", async () => {
|
||||
const ue = userEvent.setup();
|
||||
me.mockResolvedValue({ mode: "server", user: { ...user, mustChangePassword: true } });
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
const save = await screen.findByRole("button", { name: "Save and continue" });
|
||||
expect(screen.queryByText("app")).toBeNull();
|
||||
await ue.type(screen.getByLabelText("Current password"), "123");
|
||||
await ue.type(screen.getByLabelText("New password"), "weakpass");
|
||||
await ue.type(screen.getByLabelText("Confirm new password"), "weakpass");
|
||||
expect(save).toBeDisabled();
|
||||
await ue.clear(screen.getByLabelText("New password"));
|
||||
await ue.clear(screen.getByLabelText("Confirm new password"));
|
||||
await ue.type(screen.getByLabelText("New password"), "Green-Tea-42");
|
||||
await ue.type(screen.getByLabelText("Confirm new password"), "Green-Tea-42");
|
||||
await ue.click(save);
|
||||
expect(changePassword).toHaveBeenCalledWith("123", "Green-Tea-42");
|
||||
expect(await screen.findByText("app")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the app in local mode", async () => {
|
||||
me.mockResolvedValue({ mode: "local" });
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
|
||||
@@ -58,14 +58,25 @@ describe("MarkdownPreview", () => {
|
||||
describe("FileViewer — markdown preview", () => {
|
||||
const data = { text: "# Hello\n", truncated: false } as never;
|
||||
|
||||
it("switches a markdown file between raw and preview", async () => {
|
||||
it("opens a markdown file rendered, and switches it to raw and back", async () => {
|
||||
render(<FileViewer state={{ path: "/x/CLAUDE.md", data, error: null }} onClose={vi.fn()} />);
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Raw" }));
|
||||
expect(screen.queryByTestId("markdown-preview")).toBeNull();
|
||||
expect(screen.getByText("# Hello")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Preview" }));
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Raw" }));
|
||||
expect(screen.queryByTestId("markdown-preview")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens it as source when Settings → Appearance says Raw", () => {
|
||||
window.localStorage.setItem("agenthub.markdown.view", "raw");
|
||||
try {
|
||||
render(<FileViewer state={{ path: "/x/CLAUDE.md", data, error: null }} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId("markdown-preview")).toBeNull();
|
||||
expect(screen.getByText("# Hello")).toBeInTheDocument();
|
||||
} finally {
|
||||
window.localStorage.removeItem("agenthub.markdown.view");
|
||||
}
|
||||
});
|
||||
|
||||
it("offers no preview for another file type", () => {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileText, FolderOpen, Package, Sparkles, Upload, X } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
import { api, type MarketplaceInfo } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { toast } from "../../lib/toast";
|
||||
@@ -374,7 +374,6 @@ export function SkillAddPanel({
|
||||
dragging ? "border-accent bg-accent/10" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<Upload className="mx-auto mb-1 h-5 w-5 text-fg-muted" />
|
||||
<p className="text-xs text-fg-secondary">{t("skill.dropHint")}</p>
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-2">
|
||||
<button
|
||||
@@ -382,14 +381,14 @@ export function SkillAddPanel({
|
||||
className="btn-ghost text-xs"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" /> {t("skill.pickFiles")}
|
||||
{t("skill.pickFiles")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
onClick={() => folderInput.current?.click()}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" /> {t("skill.pickFolder")}
|
||||
{t("skill.pickFolder")}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
@@ -569,7 +568,6 @@ export function SkillAddPanel({
|
||||
<ul className="max-h-72 space-y-0.5 overflow-auto">
|
||||
{skills.map((s) => (
|
||||
<li key={`${s.from}:${s.name}`} className="flex items-center gap-2 text-xs">
|
||||
<Sparkles className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate text-fg-secondary">{s.name}</span>
|
||||
<span className="text-fg-muted">
|
||||
{t(s.from === "user" ? "form.fromUser" : "form.fromWorkspace")}
|
||||
@@ -746,10 +744,7 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
|
||||
{info && (
|
||||
<div className="space-y-2" data-testid="market-plugins">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-fg-primary">
|
||||
<Package className="mr-1 inline h-4 w-4 text-fg-muted" />
|
||||
{info.name}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-fg-primary">{info.name}</p>
|
||||
{info.description && <p className={hint}>{info.description}</p>}
|
||||
</div>
|
||||
{info.plugins.length === 0 ? (
|
||||
@@ -949,7 +944,7 @@ export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
|
||||
className="btn-primary shrink-0 text-sm"
|
||||
disabled={busy || !file || tooBig}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" /> {t("file.upload")}
|
||||
{t("file.upload")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* already-fetched data as props (path/data/error) rather than fetching
|
||||
* itself, so it stays tool-agnostic — the orchestrator (Claude or Codex
|
||||
* config page) owns the fetch. A markdown file gets a Raw / Preview switch
|
||||
* (`MarkdownPreview`); Raw is the default.
|
||||
* (`MarkdownPreview`), opening as Settings → Appearance says
|
||||
* (`lib/markdownView.ts`: Preview by default).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -15,6 +16,7 @@ import { FileText, X, AlertCircle, Eye, Code2 } from "lucide-react";
|
||||
import type { CcFileResponse } from "../../lib/api";
|
||||
import { CopyButton } from "./configPrimitives";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||
|
||||
export function FileViewer({
|
||||
state,
|
||||
@@ -24,7 +26,7 @@ export function FileViewer({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [preview, setPreview] = useState(false);
|
||||
const [preview, setPreview] = useState(readMarkdownPreview);
|
||||
const canPreview = isMarkdown(state.path) && !!state.data;
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panelRef, onClose);
|
||||
|
||||
@@ -96,6 +96,9 @@ const TRANSCRIPTS_REFRESH_MS = 15000;
|
||||
// at most this often: the first event still fetches at once, the last is never
|
||||
// lost, and a long transcript is not re-read for every single frame.
|
||||
const EVENT_FETCH_MS = 1500;
|
||||
// The server saw this session's transcript grow (`transcript_changed`, itself
|
||||
// at most every 250 ms): the new lines are on disk, so read them nearly at once.
|
||||
const PUSH_FETCH_MS = 300;
|
||||
|
||||
interface ConversationViewProps {
|
||||
sessionId: string;
|
||||
@@ -108,6 +111,10 @@ interface ConversationViewProps {
|
||||
compact?: boolean;
|
||||
/** Opens a file named in a turn's "Edited N files" card. */
|
||||
onOpenFile?: (path: string) => void;
|
||||
/** Opens an artifact card's file in the chat's artifact panel. */
|
||||
onOpenArtifact?: (path: string) => void;
|
||||
/** The messages on screen (for an artifact's versions), as they change. */
|
||||
onMessages?: (messages: TranscriptMessage[]) => void;
|
||||
/** The session runs in a pane: show the step in progress. */
|
||||
live?: boolean;
|
||||
/** Changed (a chat's send): back to the end, and stick to it. */
|
||||
@@ -133,6 +140,8 @@ function ConversationViewPane({
|
||||
fill = false,
|
||||
compact = false,
|
||||
onOpenFile,
|
||||
onOpenArtifact,
|
||||
onMessages,
|
||||
live = false,
|
||||
follow = 0,
|
||||
waiting = false,
|
||||
@@ -144,19 +153,21 @@ function ConversationViewPane({
|
||||
}: ConversationViewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
const shown = messages;
|
||||
// A live pane's session has no row until its first hook — and Claude Code
|
||||
// runs no hook before the folder is trusted, so a trust prompt (or any
|
||||
// other question before the session starts) waits unseen in the terminal.
|
||||
const [notStarted, setNotStarted] = useState(false);
|
||||
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
|
||||
useEffect(() => onMessages?.(shown), [shown, onMessages]);
|
||||
useEffect(() => {
|
||||
if (!onContext) return;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
for (let i = shown.length - 1; i >= 0; i--) {
|
||||
const m = shown[i]!;
|
||||
if (m.type === "assistant" && m.usage)
|
||||
return onContext(promptTokens(m.usage), m.model ?? null);
|
||||
}
|
||||
}, [messages, onContext]);
|
||||
}, [shown, onContext]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
@@ -209,12 +220,13 @@ function ConversationViewPane({
|
||||
const agentLinks = useMemo<AgentLinks>(() => {
|
||||
const ids = new Map<string, string>();
|
||||
for (const tr of transcripts) if (tr.tool_use_id) ids.set(tr.tool_use_id, tr.id);
|
||||
return { ids, notices: agentNotices(messages), live, render: renderAgent };
|
||||
}, [transcripts, messages, live, renderAgent]);
|
||||
return { ids, notices: agentNotices(shown), live, render: renderAgent };
|
||||
}, [transcripts, shown, live, renderAgent]);
|
||||
|
||||
// Load available transcript list (also rescanned on a short interval so
|
||||
// newly-spawned subagents appear in the dropdown without a page reload).
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
let cancelled = false;
|
||||
async function loadTranscripts() {
|
||||
try {
|
||||
@@ -254,6 +266,12 @@ function ConversationViewPane({
|
||||
firstLineRef.current = 0;
|
||||
|
||||
async function load() {
|
||||
// No session yet: no history.
|
||||
if (!sessionId) {
|
||||
setMessages([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
@@ -297,6 +315,7 @@ function ConversationViewPane({
|
||||
// parsed message we've seen. The server already de-overlaps via
|
||||
// afterLine, so we can safely append.
|
||||
const fetchNewMessages = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
if (fetchingRef.current) {
|
||||
// Coalesce: remember a trigger arrived during this fetch and re-run
|
||||
// exactly once when the in-flight request settles.
|
||||
@@ -358,7 +377,12 @@ function ConversationViewPane({
|
||||
() => throttle(() => void fetchNewMessages(), EVENT_FETCH_MS),
|
||||
[fetchNewMessages]
|
||||
);
|
||||
const fetchPushed = useMemo(
|
||||
() => throttle(() => void fetchNewMessages(), PUSH_FETCH_MS),
|
||||
[fetchNewMessages]
|
||||
);
|
||||
useEffect(() => () => fetchSoon.cancel(), [fetchSoon]);
|
||||
useEffect(() => () => fetchPushed.cancel(), [fetchPushed]);
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
|
||||
// Hidden tab or hidden pane: catching up once on show is enough.
|
||||
@@ -367,13 +391,14 @@ function ConversationViewPane({
|
||||
fetchSoon();
|
||||
return;
|
||||
}
|
||||
if (msg.type !== "new_event") return;
|
||||
if (msg.type !== "new_event" && msg.type !== "transcript_changed") return;
|
||||
const data = msg.data as { session_id?: string };
|
||||
if (data.session_id !== sessionId) return;
|
||||
fetchSoon();
|
||||
if (msg.type === "transcript_changed") fetchPushed();
|
||||
else fetchSoon();
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [sessionId, fetchSoon, onScreen]);
|
||||
}, [sessionId, fetchSoon, fetchPushed, onScreen]);
|
||||
|
||||
onShowRef.current = () => {
|
||||
if (document.visibilityState === "visible") void fetchNewMessages();
|
||||
@@ -535,7 +560,7 @@ function ConversationViewPane({
|
||||
|
||||
// Auto-scroll to bottom after initial load
|
||||
useEffect(() => {
|
||||
if (!loading && messages.length > 0) {
|
||||
if (!loading && shown.length > 0) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -626,7 +651,7 @@ function ConversationViewPane({
|
||||
>
|
||||
{t("conversation.loading")}
|
||||
</div>
|
||||
) : messages.length === 0 && notStarted ? (
|
||||
) : shown.length === 0 && notStarted ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||
@@ -642,7 +667,7 @@ function ConversationViewPane({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
) : shown.length === 0 ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||
@@ -651,9 +676,10 @@ function ConversationViewPane({
|
||||
</div>
|
||||
) : (
|
||||
<MessageList
|
||||
messages={messages}
|
||||
messages={shown}
|
||||
loading={false}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
live={live}
|
||||
waiting={waiting}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
|
||||
@@ -112,7 +112,11 @@ import {
|
||||
Search,
|
||||
Globe,
|
||||
Wrench,
|
||||
Image as ImageIcon,
|
||||
Code2,
|
||||
Table2,
|
||||
} from "lucide-react";
|
||||
import { artifactKind, isArtifact, type ArtifactKind } from "../../lib/artifactView";
|
||||
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
||||
import { api } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
@@ -148,6 +152,8 @@ interface MessageListProps {
|
||||
loading: boolean;
|
||||
/** Opens a file named in the "Edited N files" card. */
|
||||
onOpenFile?: (path: string) => void;
|
||||
/** Opens an artifact card's file in the chat's artifact panel. */
|
||||
onOpenArtifact?: (path: string) => void;
|
||||
/** The session is running in a pane: show the step in progress. */
|
||||
live?: boolean;
|
||||
/** The CLI is asking the user something (a permission prompt): the live
|
||||
@@ -604,30 +610,91 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
|
||||
|
||||
/** Claude Desktop's end-of-turn card: the files the turn changed, each with
|
||||
* the lines added and removed. A call still running or failed is left out. */
|
||||
const ARTIFACT_ICON: Partial<Record<ArtifactKind, typeof FileText>> = {
|
||||
html: Globe,
|
||||
svg: ImageIcon,
|
||||
markdown: FileText,
|
||||
mermaid: Workflow,
|
||||
react: Code2,
|
||||
csv: Table2,
|
||||
};
|
||||
|
||||
/** Claude Desktop's artifact card: what the model made, opened beside the chat. */
|
||||
function ArtifactCard({ path, onOpen }: { path: string; onOpen?: (path: string) => void }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const kind = artifactKind(path);
|
||||
const Icon = ARTIFACT_ICON[kind] ?? FileText;
|
||||
const name = path.split(/[\\/]/).pop() || path;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="artifact-card"
|
||||
title={path}
|
||||
disabled={!onOpen}
|
||||
onClick={() => onOpen?.(path)}
|
||||
draggable
|
||||
onDragStart={(e) => dragAttachments(e, [projectAttachment(path)])}
|
||||
className="flex w-full max-w-md items-center gap-3 rounded-xl border border-border bg-surface-1 px-3 py-2.5 text-left transition-colors enabled:hover:border-border-light enabled:hover:bg-surface-2"
|
||||
>
|
||||
<span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg border border-border bg-surface-2">
|
||||
<Icon className="h-4 w-4 text-fg-secondary" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-fg-primary">
|
||||
{name.replace(/\.[A-Za-z0-9]+$/, "")}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-fg-muted">
|
||||
{t(`conversation.artifact.${kind}`, { defaultValue: kind })}
|
||||
{onOpen ? ` · ${t("conversation.artifact.open")}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function EditedFilesCard({
|
||||
blocks,
|
||||
toolResultMap,
|
||||
onOpenFile,
|
||||
onOpenArtifact,
|
||||
pending = false,
|
||||
}: {
|
||||
blocks: TranscriptContent[];
|
||||
toolResultMap: Map<string, TranscriptContent>;
|
||||
onOpenFile?: (path: string) => void;
|
||||
/** Opens an artifact (a page, document, diagram…) in the chat's own panel. */
|
||||
onOpenArtifact?: (path: string) => void;
|
||||
/** The turn is still working: only its artifact cards so far. */
|
||||
pending?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
// `counted`: a result carried its +/- lines (an older server sends none).
|
||||
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
|
||||
// Written whole in this turn (a Write): a component made here is an artifact.
|
||||
const created = new Set<string>();
|
||||
for (const b of blocks) {
|
||||
const input = (b.input ?? {}) as Record<string, unknown>;
|
||||
const p = input.file_path ?? input.notebook_path;
|
||||
const r = b.id ? toolResultMap.get(b.id) : undefined;
|
||||
if (typeof p !== "string" || !r || r.is_error) continue;
|
||||
if (b.name === "Write") created.add(p);
|
||||
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
|
||||
f.added += r.diff?.added ?? 0;
|
||||
f.removed += r.diff?.removed ?? 0;
|
||||
f.counted ||= !!r.diff;
|
||||
files.set(p, f);
|
||||
}
|
||||
if (files.size === 0) return null;
|
||||
const artifacts = [...files.keys()].filter((p) => isArtifact(p, created.has(p)));
|
||||
const cards = artifacts.length > 0 && (
|
||||
<div data-testid="artifact-cards" className="flex flex-col gap-2">
|
||||
{artifacts.map((p) => (
|
||||
<ArtifactCard key={p} path={p} onOpen={onOpenArtifact ?? onOpenFile} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
if (pending) return cards || null;
|
||||
for (const p of artifacts) files.delete(p);
|
||||
if (files.size === 0) return cards || null;
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
let counted = false;
|
||||
@@ -643,7 +710,7 @@ function EditedFilesCard({
|
||||
<span className="text-status-danger">−{r}</span>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
const list = (
|
||||
<div data-testid="edited-files" className="overflow-hidden rounded-xl border border-border">
|
||||
<div
|
||||
draggable
|
||||
@@ -673,11 +740,21 @@ function EditedFilesCard({
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return cards ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{cards}
|
||||
{list}
|
||||
</div>
|
||||
) : (
|
||||
list
|
||||
);
|
||||
}
|
||||
const EditedFiles = memo(
|
||||
EditedFilesCard,
|
||||
(p, n) =>
|
||||
p.onOpenFile === n.onOpenFile &&
|
||||
p.onOpenArtifact === n.onOpenArtifact &&
|
||||
p.pending === n.pending &&
|
||||
sameBlocks(p.blocks, n.blocks) &&
|
||||
sameResults(n.blocks, p.toolResultMap, n.toolResultMap)
|
||||
);
|
||||
@@ -688,6 +765,7 @@ function MessageListView({
|
||||
messages,
|
||||
loading,
|
||||
onOpenFile,
|
||||
onOpenArtifact,
|
||||
live = false,
|
||||
waiting = false,
|
||||
onOpenTerminal,
|
||||
@@ -780,14 +858,16 @@ function MessageListView({
|
||||
/>
|
||||
);
|
||||
case "edits":
|
||||
// The card closes a finished turn; one still working has none yet.
|
||||
if (pending && item === items[items.length - 1]) return null;
|
||||
// The card closes a finished turn; one still working shows
|
||||
// only the artifacts it made so far, as Claude Desktop does.
|
||||
return (
|
||||
<EditedFiles
|
||||
key={item.key}
|
||||
blocks={item.blocks}
|
||||
toolResultMap={toolResultMap}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
pending={!!pending && item === items[items.length - 1]}
|
||||
/>
|
||||
);
|
||||
case "done":
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @file ConversationView.pushed.test.tsx
|
||||
* @description The server says a session's transcript grew
|
||||
* (`transcript_changed`): the chat showing that session reads the new lines
|
||||
* at once, and again within a short window after a burst — not at the hook
|
||||
* throttle's 1.5 s pace — while another session's notice fetches nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConversationView } from "../ConversationView";
|
||||
import { api } from "../../../lib/api";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
sessions: {
|
||||
transcripts: vi.fn(async () => ({ transcripts: [] })),
|
||||
transcript: vi.fn(async () => ({
|
||||
messages: [
|
||||
{
|
||||
type: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
timestamp: "2026-10-21T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
has_more: false,
|
||||
first_line: 1,
|
||||
last_line: 1,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const pushed = (session_id: string) =>
|
||||
act(() => eventBus.publish({ type: "transcript_changed", data: { session_id } } as never));
|
||||
const calls = () => vi.mocked(api.sessions.transcript).mock.calls.length;
|
||||
|
||||
describe("ConversationView on transcript_changed", () => {
|
||||
it("reads its own session's new lines at once, and a burst within 300 ms", async () => {
|
||||
render(<ConversationView sessionId="s1" fill live />);
|
||||
await screen.findByText("hello");
|
||||
vi.useFakeTimers();
|
||||
const before = calls();
|
||||
pushed("s1");
|
||||
expect(calls()).toBe(before + 1);
|
||||
pushed("s1");
|
||||
pushed("s1");
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
});
|
||||
expect(calls()).toBe(before + 2);
|
||||
});
|
||||
|
||||
it("ignores another session's transcript", async () => {
|
||||
render(<ConversationView sessionId="s1" fill live />);
|
||||
await screen.findByText("hello");
|
||||
const before = calls();
|
||||
pushed("s2");
|
||||
await act(async () => {});
|
||||
expect(calls()).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,7 @@ describe("the Edited N files card", () => {
|
||||
user("fix it"),
|
||||
call("1", "Edit", "/w/a.ts"),
|
||||
result("1", { added: 3, removed: 1 }),
|
||||
call("2", "Write", "/w/b.md"),
|
||||
call("2", "Write", "/w/b.json"),
|
||||
result("2", { added: 10, removed: 0 }),
|
||||
call("3", "Edit", "/w/a.ts"),
|
||||
result("3", { added: 2, removed: 2 }),
|
||||
@@ -65,8 +65,35 @@ describe("the Edited N files card", () => {
|
||||
expect(within(card).getByText("+15")).toBeTruthy();
|
||||
expect(within(card).getByText("+5")).toBeTruthy(); // a.ts: 3+2 added
|
||||
expect(within(card).queryByText("c.ts")).toBeNull(); // failed
|
||||
fireEvent.click(within(card).getByTitle("/w/b.md"));
|
||||
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
|
||||
fireEvent.click(within(card).getByTitle("/w/b.json"));
|
||||
expect(onOpenFile).toHaveBeenCalledWith("/w/b.json");
|
||||
});
|
||||
|
||||
it("shows a page or document the turn made as an artifact card, opened in the chat's panel", () => {
|
||||
const onOpenFile = vi.fn();
|
||||
const onOpenArtifact = vi.fn();
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
messages={[
|
||||
user("make a page"),
|
||||
call("1", "Write", "/w/page.html"),
|
||||
result("1", { added: 20, removed: 0 }),
|
||||
call("2", "Edit", "/w/App.tsx"),
|
||||
result("2", { added: 1, removed: 1 }),
|
||||
user("thanks"),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const cards = screen.getAllByTestId("artifact-card");
|
||||
// A .tsx it only edited is project code, not an artifact.
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0]!.textContent).toContain("page");
|
||||
fireEvent.click(cards[0]!);
|
||||
expect(onOpenArtifact).toHaveBeenCalledWith("/w/page.html");
|
||||
expect(within(screen.getByTestId("edited-files")).getByTitle("/w/App.tsx")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("drags a row as its @path attachment, and the header as every file", () => {
|
||||
@@ -77,7 +104,7 @@ describe("the Edited N files card", () => {
|
||||
user("fix it"),
|
||||
call("1", "Edit", "/w/a.ts"),
|
||||
result("1", { added: 1, removed: 0 }),
|
||||
call("2", "Write", "/w/b.md"),
|
||||
call("2", "Write", "/w/b.json"),
|
||||
result("2", { added: 2, removed: 0 }),
|
||||
]}
|
||||
/>
|
||||
@@ -90,14 +117,14 @@ describe("the Edited N files card", () => {
|
||||
});
|
||||
return data;
|
||||
};
|
||||
const one = drag(within(card).getByTitle("/w/b.md"));
|
||||
const one = drag(within(card).getByTitle("/w/b.json"));
|
||||
expect(JSON.parse(one["application/x-agenthub-attach"]!)).toEqual([
|
||||
{ path: "@/w/b.md", name: "b.md" },
|
||||
{ path: "@/w/b.json", name: "b.json" },
|
||||
]);
|
||||
expect(one["text/plain"]).toBe("/w/b.md");
|
||||
expect(one["text/plain"]).toBe("/w/b.json");
|
||||
const all = drag(within(card).getByText("Edited 2 files"));
|
||||
expect(
|
||||
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
|
||||
).toEqual(["@/w/a.ts", "@/w/b.md"]);
|
||||
).toEqual(["@/w/a.ts", "@/w/b.json"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,6 +106,7 @@ import {
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { pasteToLane } from "../../lib/panePaste";
|
||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||
import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
@@ -873,9 +874,10 @@ export function LaneChangesPanel({
|
||||
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
|
||||
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
|
||||
const [whole, setWhole] = useState(false);
|
||||
// A whole markdown file shown rendered instead of as source; per file.
|
||||
const [mdPreview, setMdPreview] = useState(false);
|
||||
useEffect(() => setMdPreview(false), [openFile]);
|
||||
// A whole markdown file shown rendered instead of as source; per file, each
|
||||
// one opening as Settings → Appearance says (`lib/markdownView.ts`).
|
||||
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
||||
useEffect(() => setMdPreview(readMarkdownPreview()), [openFile]);
|
||||
const [data, setData] = useState<LaneChanges | null>(null);
|
||||
// A folder that is not a repo (an empty new workspace, a parent of clones)
|
||||
// has no changes, review or history — only its files, which Files reads
|
||||
@@ -1422,6 +1424,27 @@ export function LaneChangesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
// Discard all: every non-conflicted file in one `discard`, asked once in a
|
||||
// dialog that names how many untracked files it will delete.
|
||||
const discardAll = async () => {
|
||||
const rows = files.filter((f) => !f.conflicted);
|
||||
if (!rows.length) return;
|
||||
const ok = await confirmDialog({
|
||||
title: t("changes.discardAllTitle"),
|
||||
message: t("changes.discardAllConfirm", {
|
||||
count: rows.length,
|
||||
untracked: rows.filter((f) => f.untracked).length,
|
||||
}),
|
||||
confirmLabel: t("changes.discardAll"),
|
||||
});
|
||||
if (ok)
|
||||
await act(
|
||||
"discard",
|
||||
rows.map((f) => f.path),
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
|
||||
const mergeAct = async (
|
||||
action: "resolve" | "merge-commit" | "merge-abort",
|
||||
@@ -1993,6 +2016,8 @@ export function LaneChangesPanel({
|
||||
});
|
||||
|
||||
const dirty = draft !== null && draft !== text?.lines.join("\n");
|
||||
// Rendered only where the toggle is offered: a whole markdown file.
|
||||
const mdShown = mdPreview && whole && isMarkdown(openFile);
|
||||
dirtyRef.current = dirty;
|
||||
useUnsavedGuard(dirty);
|
||||
const dirtyChangeRef = useRef(onDirtyChange);
|
||||
@@ -2756,6 +2781,17 @@ export function LaneChangesPanel({
|
||||
>
|
||||
{t(amend ? "changes.amendAndPush" : "changes.commitAndPush")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-discard-all"
|
||||
disabled={busy}
|
||||
onClick={() => void discardAll()}
|
||||
title={t("changes.discardAllTitle")}
|
||||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden />
|
||||
{t("changes.discardAll")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -2995,7 +3031,7 @@ export function LaneChangesPanel({
|
||||
range === "commit" && sha ? sha : "work"
|
||||
)}
|
||||
/>
|
||||
) : !mdPreview &&
|
||||
) : !mdShown &&
|
||||
(whole || editable) &&
|
||||
(draft !== null || (editable && text === null)) ? (
|
||||
// The editor, once both texts are in: not the diff first.
|
||||
@@ -3051,7 +3087,7 @@ export function LaneChangesPanel({
|
||||
</Suspense>
|
||||
)
|
||||
) : whole ? (
|
||||
text && mdPreview ? (
|
||||
text && mdShown ? (
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
|
||||
<MarkdownPreview text={text.lines.join("\n")} />
|
||||
</div>
|
||||
|
||||
@@ -514,7 +514,7 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByText("Unversioned Files")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => {
|
||||
it("opens a whole markdown file rendered, as source after Raw, and rendered again after Preview", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
@@ -534,12 +534,13 @@ describe("LaneChangesPanel", () => {
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.click(await within(sidebar).findByText("README.md"));
|
||||
await screen.findByTestId("lane-file-editor");
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("# Hello\n\n- one");
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an image file as the picture, with no edit control", async () => {
|
||||
@@ -770,6 +771,24 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("discards every file at once only after the dialog is confirmed", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
||||
paths: ["server/lib/git-ops.js", "notes.txt", "docs/API.md"],
|
||||
confirm: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
|
||||
@@ -18,15 +18,41 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type DragEvent } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, FileText, Loader2, X } from "lucide-react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Download,
|
||||
FileText,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Share2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { dragSkill } from "../../lib/promptAttach";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { readMarkdownPreview } from "../../lib/markdownView";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { CodeBlock } from "../conversation/CodeBlock";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { toast } from "../../lib/toast";
|
||||
import type { TranscriptMessage } from "../../lib/types";
|
||||
import {
|
||||
RENDERED,
|
||||
artifactDocument,
|
||||
artifactKind,
|
||||
artifactLang,
|
||||
artifactVersions,
|
||||
frameSandbox,
|
||||
parseCsv,
|
||||
type ArtifactKind,
|
||||
} from "../../lib/artifactView";
|
||||
|
||||
/** Where a Chat pane's files can be read from. */
|
||||
export interface PreviewScope {
|
||||
@@ -146,33 +172,96 @@ function useContent(path: string, scope: PreviewScope): Content {
|
||||
return content;
|
||||
}
|
||||
|
||||
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
||||
/** A CSV/TSV as a table (the first rows; a file is not a spreadsheet). */
|
||||
function CsvTable({ text, tab }: { text: string; tab: boolean }) {
|
||||
const rows = useMemo(() => parseCsv(text, tab ? "\t" : ",").slice(0, 1000), [text, tab]);
|
||||
const [head, ...body] = rows;
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
{head && (
|
||||
<thead className="sticky top-0 bg-surface-2">
|
||||
<tr>
|
||||
{head.map((h, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className="border border-border px-2 py-1 text-left font-medium text-fg-primary"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
)}
|
||||
<tbody>
|
||||
{body.map((r, i) => (
|
||||
<tr key={i} className="odd:bg-surface-1 even:bg-surface-2/40">
|
||||
{r.map((c, j) => (
|
||||
<td key={j} className="border border-border px-2 py-1 text-fg-secondary">
|
||||
{c}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An HTML, SVG, Mermaid or React artifact, running in its own sandbox:
|
||||
* scripts (none for an SVG) but never the dashboard's origin. */
|
||||
function ArtifactFrame({ kind, text, title }: { kind: ArtifactKind; text: string; title: string }) {
|
||||
const { theme } = useTheme();
|
||||
const doc = useMemo(() => artifactDocument(kind, text, theme === "dark"), [kind, text, theme]);
|
||||
return (
|
||||
<iframe
|
||||
data-testid="artifact-frame"
|
||||
title={title}
|
||||
srcDoc={doc ?? ""}
|
||||
sandbox={frameSandbox(kind)}
|
||||
referrerPolicy="no-referrer"
|
||||
className={`min-h-0 w-full flex-1 border-0 ${kind === "html" || kind === "react" ? "bg-white" : ""}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type View = "preview" | "code";
|
||||
|
||||
/** A file's first tab: Preview where it renders — Markdown as Settings → Appearance says. */
|
||||
function defaultView(path: string): View {
|
||||
const kind = artifactKind(path);
|
||||
if (!RENDERED.has(kind)) return "code";
|
||||
return kind === "markdown" && !readMarkdownPreview() ? "code" : "preview";
|
||||
}
|
||||
|
||||
function Body({ path, content, view }: { path: string; content: Content; view: View }) {
|
||||
const { t } = useTranslation("run");
|
||||
const loaded = useContent(path, scope);
|
||||
const kind = artifactKind(path);
|
||||
// An image the route refused (gone, server mode) shows like no preview.
|
||||
const [broken, setBroken] = useState<string | null>(null);
|
||||
const content: Content =
|
||||
loaded.kind === "image" && loaded.src === broken ? { kind: "none" } : loaded;
|
||||
if (content.kind === "loading") {
|
||||
const shown: Content =
|
||||
content.kind === "image" && content.src === broken ? { kind: "none" } : content;
|
||||
if (shown.kind === "loading") {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (content.kind === "image") {
|
||||
if (shown.kind === "image") {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto p-4">
|
||||
<img
|
||||
src={content.src}
|
||||
src={shown.src}
|
||||
alt={baseName(path)}
|
||||
onError={() => setBroken(content.src)}
|
||||
onError={() => setBroken(shown.src)}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (content.kind === "none") {
|
||||
if (shown.kind === "none") {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
|
||||
<FileText className="h-8 w-8" />
|
||||
@@ -181,69 +270,244 @@ function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{isMarkdown(path) ? (
|
||||
<MarkdownPreview text={content.text} className="p-4" />
|
||||
) : (
|
||||
<pre className="p-4 font-mono text-xs leading-relaxed text-fg-secondary">
|
||||
{content.text}
|
||||
</pre>
|
||||
)}
|
||||
{content.truncated && (
|
||||
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
|
||||
)}
|
||||
</div>
|
||||
const note = shown.truncated && (
|
||||
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
|
||||
);
|
||||
if (view === "code" || !RENDERED.has(kind)) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||
<CodeBlock code={shown.text} lang={artifactLang(path)} maxHeight="none" showLineNumbers />
|
||||
{note}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === "markdown") {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<MarkdownPreview text={shown.text} className="p-4" />
|
||||
{note}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />;
|
||||
return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />;
|
||||
}
|
||||
|
||||
/** One file: header (name · EXT, Copy path, Close) over its content. */
|
||||
const iconBtn =
|
||||
"flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-40";
|
||||
|
||||
/** Saves text (or an image's URL) as a file through the browser. */
|
||||
function download(name: string, content: Content) {
|
||||
const a = document.createElement("a");
|
||||
if (content.kind === "image") a.href = content.src;
|
||||
else if (content.kind === "text")
|
||||
a.href = URL.createObjectURL(new Blob([content.text], { type: "text/plain;charset=utf-8" }));
|
||||
else return;
|
||||
a.download = name;
|
||||
a.click();
|
||||
if (content.kind === "text") setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
}
|
||||
|
||||
/** One file, Claude Desktop's artifact view: name · EXT, Preview | Code, the
|
||||
* versions the conversation made of it, Copy, Download, Share to the
|
||||
* Artifacts Hub, full screen, Close. */
|
||||
export function FilePreview({
|
||||
path,
|
||||
scope,
|
||||
onClose,
|
||||
className = "",
|
||||
messages,
|
||||
}: {
|
||||
path: string;
|
||||
scope: PreviewScope;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
/** The conversation, for the versions its Write/Edit calls left. */
|
||||
messages?: TranscriptMessage[];
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
return (
|
||||
const kind = artifactKind(path);
|
||||
const loaded = useContent(path, scope);
|
||||
const [view, setView] = useState<View>(() => defaultView(path));
|
||||
const [full, setFull] = useState(false);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const versions = useMemo(
|
||||
() => (messages ? artifactVersions(messages, path) : []),
|
||||
[messages, path]
|
||||
);
|
||||
// null: the newest — the file as it is now.
|
||||
const [picked, setPicked] = useState<number | null>(null);
|
||||
// Another file opens on its own default tab, at its newest version.
|
||||
useEffect(() => {
|
||||
setPicked(null);
|
||||
setView(defaultView(path));
|
||||
}, [path]);
|
||||
const last = Math.max(versions.length - 1, 0);
|
||||
const at = picked ?? last;
|
||||
const older = picked !== null && picked < last ? versions[picked] : null;
|
||||
const content: Content =
|
||||
older && older.content !== null
|
||||
? { kind: "text", text: older.content, truncated: false }
|
||||
: loaded;
|
||||
const known = (i: number) => i === last || versions[i]?.content != null;
|
||||
const step = (dir: -1 | 1) => {
|
||||
for (let i = at + dir; i >= 0 && i <= last; i += dir) {
|
||||
if (known(i)) return setPicked(i === last ? null : i);
|
||||
}
|
||||
};
|
||||
const share = async () => {
|
||||
if (sharing) return;
|
||||
setSharing(true);
|
||||
try {
|
||||
const file = slashed(path);
|
||||
const base = slashed(scope.laneCwd).replace(/\/+$/, "");
|
||||
if (scope.laneId !== null && base && file.startsWith(`${base}/`) && picked === null) {
|
||||
await api.artifacts.pinFile({
|
||||
laneId: scope.laneId,
|
||||
path: file.slice(base.length + 1),
|
||||
title: displayName(path),
|
||||
...(scope.sessionId ? { sessionId: scope.sessionId } : {}),
|
||||
});
|
||||
} else if (content.kind === "text") {
|
||||
await api.artifacts.uploadFile(new File([content.text], displayName(path)), {
|
||||
title: displayName(path),
|
||||
});
|
||||
} else throw new Error(t("chat.artifact.cannotShare"));
|
||||
toast.success(t("chat.artifact.shared"));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
};
|
||||
const panel = (
|
||||
<section
|
||||
data-testid="chat-file-preview"
|
||||
className={`flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`}
|
||||
className={
|
||||
full
|
||||
? "fixed inset-4 z-[200] flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
|
||||
: `flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`
|
||||
}
|
||||
>
|
||||
<header className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<span className="min-w-0 truncate text-sm text-fg-primary" title={path}>
|
||||
<header className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(path)}
|
||||
title={t("chat.outline.copyPath", { path })}
|
||||
className="min-w-0 truncate text-left text-sm text-fg-primary hover:underline"
|
||||
>
|
||||
{displayName(path).replace(/\.[A-Za-z0-9]+$/, "")}
|
||||
</span>
|
||||
<span className="text-xs text-fg-muted">{extOf(path)}</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
</button>
|
||||
<span className="flex-shrink-0 text-xs text-fg-muted">{extOf(path)}</span>
|
||||
{versions.length > 1 && (
|
||||
<span
|
||||
data-testid="artifact-versions"
|
||||
className="flex flex-shrink-0 items-center text-xs text-fg-muted"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(-1)}
|
||||
disabled={![...Array(at).keys()].some(known)}
|
||||
aria-label={t("chat.artifact.older")}
|
||||
title={t("chat.artifact.older")}
|
||||
className={iconBtn}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className="tabular-nums">
|
||||
{t("chat.artifact.version", { n: at + 1, total: versions.length })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(1)}
|
||||
disabled={at >= last}
|
||||
aria-label={t("chat.artifact.newer")}
|
||||
title={t("chat.artifact.newer")}
|
||||
className={iconBtn}
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex flex-shrink-0 items-center gap-1">
|
||||
{RENDERED.has(kind) && kind !== "image" && (
|
||||
<div role="tablist" className="mr-1 flex rounded-md border border-border p-0.5 text-xs">
|
||||
{(["preview", "code"] as const).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === v}
|
||||
data-testid={`artifact-tab-${v}`}
|
||||
onClick={() => setView(v)}
|
||||
className={`rounded px-2 py-0.5 ${view === v ? "bg-surface-3 text-fg-primary" : "text-fg-muted hover:text-fg-primary"}`}
|
||||
>
|
||||
{t(`chat.artifact.${v}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(path)}
|
||||
title={t("chat.outline.copyPath", { path })}
|
||||
aria-label={t("chat.outline.copyPath", { path })}
|
||||
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
data-testid="artifact-copy"
|
||||
onClick={() => void copyText(content.kind === "text" ? content.text : path)}
|
||||
title={t("chat.artifact.copy")}
|
||||
aria-label={t("chat.artifact.copy")}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => download(displayName(path), content)}
|
||||
disabled={content.kind !== "text" && content.kind !== "image"}
|
||||
title={t("chat.artifact.download")}
|
||||
aria-label={t("chat.artifact.download")}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="artifact-share"
|
||||
onClick={() => void share()}
|
||||
disabled={sharing || content.kind === "loading" || content.kind === "none"}
|
||||
title={t("chat.artifact.share")}
|
||||
aria-label={t("chat.artifact.share")}
|
||||
className={iconBtn}
|
||||
>
|
||||
{sharing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Share2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="artifact-expand"
|
||||
onClick={() => setFull((f) => !f)}
|
||||
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||
className={iconBtn}
|
||||
>
|
||||
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
title={t("chat.outline.close")}
|
||||
aria-label={t("chat.outline.close")}
|
||||
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
className={iconBtn}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<Body path={path} scope={scope} />
|
||||
<Body path={path} content={content} view={view} />
|
||||
</section>
|
||||
);
|
||||
// Full screen: over the whole page, where a pane's edges cannot clip it.
|
||||
return full ? createPortal(panel, document.body) : panel;
|
||||
}
|
||||
|
||||
/** Claude's Uploads dialog: the files on the left, the picked one on the right. */
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* new lane's own live session for its slot, or an empty pane — never the
|
||||
* previous lane's terminal. `attachRequest` (a run picked in the sidebar or a
|
||||
* tab) attaches to that tmux session only; nothing is started or resumed.
|
||||
* A Claude or Codex pane also has a Chat view (`PaneChat`): the session's
|
||||
* A pane of a `CHAT_TOOLS` tool (Claude, Codex, omp, Cursor, Antigravity) also
|
||||
* has a Chat view (`PaneChat`): the session's
|
||||
* conversation laid OVER the terminal, which stays mounted and attached under
|
||||
* it. Chat or terminal is each SESSION's own (`useSessionChat`, the toggle
|
||||
* left of the pane's split buttons); the Chat UI setting (Settings →
|
||||
@@ -29,9 +30,7 @@ import type { Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { PaneChat } from "./PaneChat";
|
||||
import { useSessionChat } from "../../lib/chatUi";
|
||||
|
||||
const CHAT_TOOLS = new Set(["claude", "codex"]);
|
||||
import { CHAT_TOOLS, useSessionChat } from "../../lib/chatUi";
|
||||
|
||||
export interface LaneConsolePaneProps {
|
||||
lanes: Lane[];
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
* in the box beside `+`. A menu the CLI asks with — AskUserQuestion, a
|
||||
* permission or trust prompt, Codex's approval — is read off the terminal
|
||||
* underneath (`readRunMenu`) and shown in the prompt box's place as buttons
|
||||
* that press its keys there (`PaneMenuCard`); its "Type something" row opens
|
||||
* that press its keys there (`PaneMenuCard`) — a permission prompt with its tool, command and a deny-with-note
|
||||
* (`PermissionMenuCard`); its "Type something" row opens
|
||||
* a field in the card, whose text is typed there as the answer
|
||||
* (`answerRunMenu`).
|
||||
* It also has Claude Desktop's activity panel (`ChatOutline`: progress,
|
||||
@@ -56,7 +57,17 @@
|
||||
|
||||
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
|
||||
import {
|
||||
ArrowUp,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
MessageCircleQuestion,
|
||||
ShieldQuestion,
|
||||
Square,
|
||||
SquareCheck,
|
||||
} from "lucide-react";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ConversationView } from "../conversation/ConversationView";
|
||||
import {
|
||||
@@ -77,7 +88,11 @@ import { Divider, showTerminal } from "./PaneGrid";
|
||||
import {
|
||||
answerRunMenu,
|
||||
chooseRunOption,
|
||||
notePaneFocus,
|
||||
onPaneOutput,
|
||||
stepRunMenu,
|
||||
interruptRun,
|
||||
readRunBusy,
|
||||
readRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
@@ -89,7 +104,9 @@ import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { setTabBadge } from "../../lib/tabBadge";
|
||||
import { fmt } from "../../lib/format";
|
||||
import { agentAwaitingReason } from "../../lib/types";
|
||||
import { agentAwaitingReason, type TranscriptMessage, type WSMessage } from "../../lib/types";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { throttle } from "../../lib/throttle";
|
||||
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
|
||||
|
||||
const MODES: [PermissionMode, string][] = [
|
||||
@@ -100,6 +117,11 @@ const MODES: [PermissionMode, string][] = [
|
||||
["bypassPermissions", "Bypass"],
|
||||
];
|
||||
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
|
||||
// Pane output re-reads the transcript at most this often: a TUI spinner
|
||||
// redraws several times a second, and each read walks the transcript file.
|
||||
const OUTPUT_FETCH_MS = 1000;
|
||||
/** A tool with no known busy line counts as at work this long after its pane last drew. */
|
||||
const OUTPUT_BUSY_MS = 2000;
|
||||
const SIDE_KEY = "agenthub.chat.panelWidth";
|
||||
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
|
||||
const outlineKey = (sessionId: string) => `agenthub.chat.panel.${sessionId}`;
|
||||
@@ -117,6 +139,7 @@ const storedSide = () => {
|
||||
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
|
||||
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
|
||||
export function contextWindow(model: string | null): number | null {
|
||||
// A Codex/omp chat reports its own window (ContextMeter's `window`).
|
||||
if (!model?.startsWith("claude-")) return null;
|
||||
return /^claude-(?:[23]|instant|(?:opus|sonnet|haiku)-4(?:-[0-5])?(?:-\d{8})?$)/.test(model)
|
||||
? 200_000
|
||||
@@ -125,7 +148,13 @@ export function contextWindow(model: string | null): number | null {
|
||||
|
||||
/** How full the context is: a ring and a percentage, amber then red as
|
||||
* Claude Code's auto-compact comes near. */
|
||||
function ContextMeter({ tokens, model }: { tokens: number; model: string | null }) {
|
||||
function ContextMeter({
|
||||
tokens,
|
||||
model,
|
||||
}: {
|
||||
tokens: number;
|
||||
model: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const size = contextWindow(model);
|
||||
if (!size || !tokens) return null;
|
||||
@@ -212,33 +241,222 @@ function PermissionChip({ runId, initial }: { runId: string; initial: Permission
|
||||
);
|
||||
}
|
||||
|
||||
/** A permission prompt (Claude's tool approval, Codex's command approval,
|
||||
* the folder-trust question): it offers a Yes and a No. */
|
||||
export function isPermissionMenu(menu: PaneMenu): boolean {
|
||||
if (menu.multi || menu.tabs) return false;
|
||||
const labels = menu.options.map((o) => o.label);
|
||||
return labels.some((l) => /^Yes\b/i.test(l)) && labels.some((l) => /^No\b/i.test(l));
|
||||
}
|
||||
|
||||
/** A menu row's label without its key hint (`Yes, proceed (y)`). */
|
||||
const bareLabel = (label: string) => label.replace(/\s+\((?:[a-z]|esc|shift\+tab)\)$/i, "");
|
||||
|
||||
/** A permission prompt read off the terminal: the tool, what it will run, and the CLI's own
|
||||
* answers as buttons — each still presses its key in the pane. "No, and
|
||||
* tell … differently" also takes a note, sent as the next message once the
|
||||
* CLI is back at its prompt. */
|
||||
function PermissionMenuCard({
|
||||
menu,
|
||||
onChoose,
|
||||
onDenyNote,
|
||||
onCancel,
|
||||
}: {
|
||||
menu: PaneMenu;
|
||||
onChoose: (index: number) => void;
|
||||
onDenyNote: (index: number, note: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const [noting, setNoting] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const q = menu.question.split("\n");
|
||||
const title = menu.detail?.[0] ?? q[0] ?? "";
|
||||
const body = (menu.detail ? menu.detail.slice(1) : q.slice(1)).join("\n");
|
||||
const ask = menu.detail ? menu.question : "";
|
||||
const yes = menu.options.findIndex((o) => /^Yes\b/i.test(o.label));
|
||||
const tell = menu.options.findIndex((o) => /^No, and tell\b/i.test(o.label));
|
||||
const sendNote = () => {
|
||||
if (note.trim() && tell >= 0) onDenyNote(tell, note.trim());
|
||||
};
|
||||
return (
|
||||
<div data-testid="pane-chat-permission" className="flex flex-col gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldQuestion className="mt-0.5 h-4 w-4 flex-shrink-0 text-status-warning" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
|
||||
{title}
|
||||
</p>
|
||||
{ask && <p className="text-xs text-fg-muted">{ask}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{body && (
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-fg-secondary">
|
||||
{body}
|
||||
</pre>
|
||||
)}
|
||||
{noting ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-accent/50 bg-surface-2 px-3 py-1">
|
||||
<input
|
||||
autoFocus
|
||||
data-testid="pane-chat-permission-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
sendNote();
|
||||
} else if (e.key === "Escape") setNoting(false);
|
||||
}}
|
||||
placeholder={t("chat.request.notePlaceholder")}
|
||||
aria-label={t("chat.request.notePlaceholder")}
|
||||
className="min-w-0 flex-1 bg-transparent py-0.5 text-sm text-fg-primary outline-none placeholder:text-fg-muted"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={sendNote}
|
||||
disabled={!note.trim()}
|
||||
className="btn-secondary text-xs disabled:opacity-40"
|
||||
>
|
||||
{t("chat.request.send")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{menu.options.map((o, i) => (
|
||||
<button
|
||||
key={`${i}-${o.label}`}
|
||||
type="button"
|
||||
data-testid="pane-chat-permission-option"
|
||||
autoFocus={i === yes}
|
||||
onClick={() => onChoose(i)}
|
||||
title={o.hint || undefined}
|
||||
className={`${i === yes ? "btn-primary" : "btn-secondary"} max-w-full break-words text-left text-xs`}
|
||||
>
|
||||
{bareLabel(o.label)}
|
||||
</button>
|
||||
))}
|
||||
{tell >= 0 && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-permission-tell"
|
||||
onClick={() => setNoting(true)}
|
||||
className="text-xs text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
|
||||
>
|
||||
{t("chat.request.denyWithNote")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="ml-auto rounded-md border border-border px-2 py-0.5 text-xs text-fg-muted hover:bg-surface-3"
|
||||
>
|
||||
{t("chat.menu.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The menu the CLI asks with, as buttons: a pick presses its key in the
|
||||
* pane; its "Type something" row opens a field right there instead —
|
||||
* pressing that row's digit again would type the digit into the CLI's own. */
|
||||
* pressing that row's digit again would type the digit into the CLI's own.
|
||||
* A permission prompt takes its own layout (`PermissionMenuCard`), any
|
||||
* other menu the question layout. */
|
||||
export function PaneMenuCard({
|
||||
menu,
|
||||
onChoose,
|
||||
onAnswer,
|
||||
onCancel,
|
||||
onStep,
|
||||
onDenyNote,
|
||||
}: {
|
||||
menu: PaneMenu;
|
||||
onChoose: (index: number) => void;
|
||||
onAnswer: (text: string) => void;
|
||||
onCancel: () => void;
|
||||
/** Previous (-1) / next (1) question of a multi-question AskUserQuestion. */
|
||||
onStep?: (dir: -1 | 1) => void;
|
||||
/** A permission prompt's "No, and tell …", with the note to send after it. */
|
||||
onDenyNote?: (index: number, note: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const [typing, setTyping] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const submit = () => draft.trim() && onAnswer(draft);
|
||||
if (isPermissionMenu(menu))
|
||||
return (
|
||||
<div
|
||||
data-testid="pane-chat-menu"
|
||||
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
|
||||
>
|
||||
<PermissionMenuCard
|
||||
menu={menu}
|
||||
onChoose={onChoose}
|
||||
onDenyNote={onDenyNote ?? ((i) => onChoose(i))}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-testid="pane-chat-menu"
|
||||
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
|
||||
>
|
||||
{menu.tabs && (
|
||||
<div
|
||||
data-testid="pane-chat-menu-tabs"
|
||||
className="mb-2 flex flex-wrap items-center gap-1 text-xs"
|
||||
>
|
||||
{onStep && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStep(-1)}
|
||||
aria-label={t("chat.menu.prev")}
|
||||
title={t("chat.menu.prev")}
|
||||
className="rounded-md border border-border p-0.5 text-fg-muted hover:bg-surface-3"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{menu.tabs.map((tab, i) => (
|
||||
<span
|
||||
key={`${i}-${tab.label}`}
|
||||
className={`flex items-center gap-1 rounded-md border px-1.5 py-0.5 ${
|
||||
i === menu.tab
|
||||
? "border-accent/50 bg-surface-2 text-fg-primary"
|
||||
: "border-border text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{tab.done ? (
|
||||
<Check className="h-3 w-3 text-accent" />
|
||||
) : (
|
||||
<Square className="h-3 w-3" />
|
||||
)}
|
||||
{tab.label}
|
||||
</span>
|
||||
))}
|
||||
{onStep && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStep(1)}
|
||||
aria-label={t("chat.menu.next")}
|
||||
title={t("chat.menu.next")}
|
||||
className="rounded-md border border-border p-0.5 text-fg-muted hover:bg-surface-3"
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{menu.question && (
|
||||
<p className="mb-2 whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
|
||||
{menu.question}
|
||||
</p>
|
||||
<div className="mb-2 flex items-start gap-2">
|
||||
<MessageCircleQuestion className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent" />
|
||||
<p className="whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
|
||||
{menu.question}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
{menu.options.map((o, i) =>
|
||||
@@ -338,9 +556,10 @@ export const PaneChat = memo(function PaneChat({
|
||||
const runId = run.id;
|
||||
const claude = run.tool === "claude";
|
||||
// The unsent prompt outlives the terminal view and a reload (`lib/chatDraft`).
|
||||
const [prompt, setPrompt] = useState(() => readChatDraft(sessionId).prompt);
|
||||
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(sessionId).images);
|
||||
useEffect(() => writeChatDraft(sessionId, prompt, images), [sessionId, prompt, images]);
|
||||
const draftKey = sessionId;
|
||||
const [prompt, setPrompt] = useState(() => readChatDraft(draftKey).prompt);
|
||||
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(draftKey).images);
|
||||
useEffect(() => writeChatDraft(draftKey, prompt, images), [draftKey, prompt, images]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [follow, setFollow] = useState(0);
|
||||
const [model, setModel] = useState(run.model ?? "");
|
||||
@@ -416,6 +635,46 @@ export const PaneChat = memo(function PaneChat({
|
||||
useEffect(() => {
|
||||
void readWorking();
|
||||
}, [readWorking]);
|
||||
// The terminal underneath drew something: the CLI is at work, so its
|
||||
// transcript may have grown — read it now, for any tool, hooks or not. A
|
||||
// spinner redraws all the time, hence the throttle.
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
const poke = throttle(
|
||||
() =>
|
||||
eventBus.publish({
|
||||
type: "transcript_changed",
|
||||
data: { session_id: sessionId },
|
||||
} as unknown as WSMessage),
|
||||
OUTPUT_FETCH_MS
|
||||
);
|
||||
const off = onPaneOutput(runId, poke);
|
||||
return () => {
|
||||
off();
|
||||
poke.cancel();
|
||||
};
|
||||
}, [runId, sessionId]);
|
||||
// The terminal's own word on a turn: its busy line (BUSY_LINE) while it
|
||||
// shows, else — a tool whose line we do not know — output in the last
|
||||
// OUTPUT_BUSY_MS. Hooks miss a tool that sends none (omp, Cursor, Codex
|
||||
// without its opt-in hooks), and arrive late on a dashboard they skip.
|
||||
const [screenBusy, setScreenBusy] = useState(false);
|
||||
useEffect(() => {
|
||||
let last = 0;
|
||||
const off = onPaneOutput(runId, () => (last = Date.now()));
|
||||
const read = () => {
|
||||
const known = readRunBusy(runId, run.tool);
|
||||
setScreenBusy(known ?? (last > 0 && Date.now() - last < OUTPUT_BUSY_MS));
|
||||
};
|
||||
read();
|
||||
const timer = window.setInterval(() => {
|
||||
if (isShown(onScreen)) read();
|
||||
}, 700);
|
||||
return () => {
|
||||
off();
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [runId, run.tool, onScreen]);
|
||||
useLiveRefresh(readWorking, {
|
||||
throttleMs: 500,
|
||||
// While Stop shows, also poll: a missed agent_updated (the hook reached
|
||||
@@ -444,6 +703,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
}, 700);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [runId, onScreen]);
|
||||
const busy = working || screenBusy;
|
||||
const asking = waiting || menu !== null;
|
||||
// Gone until the pane redraws: no second press on a stale menu.
|
||||
const choose = (index: number) => {
|
||||
@@ -456,15 +716,22 @@ export const PaneChat = memo(function PaneChat({
|
||||
setFollow((n) => n + 1);
|
||||
} else toast.error(t("chat.notSent"));
|
||||
};
|
||||
// "No, and tell … differently": the CLI drops back to its prompt, where
|
||||
// the note is what the user would type next.
|
||||
const denyWithNote = (index: number, note: string) => {
|
||||
if (!menu || !chooseRunOption(runId, menu, index)) return void toast.error(t("chat.notSent"));
|
||||
setMenu(null);
|
||||
window.setTimeout(() => void command(note), 800);
|
||||
};
|
||||
|
||||
// A hidden tab says when this session waits on the user or its turn ended.
|
||||
const was = useRef({ working: false, asking: false });
|
||||
useEffect(() => {
|
||||
if (asking) setTabBadge(runId, "waiting");
|
||||
else if (working) setTabBadge(runId, null);
|
||||
else if (busy) setTabBadge(runId, null);
|
||||
else if (was.current.working || was.current.asking) setTabBadge(runId, "done");
|
||||
was.current = { working, asking };
|
||||
}, [runId, working, asking]);
|
||||
was.current = { working: busy, asking };
|
||||
}, [runId, busy, asking]);
|
||||
useEffect(() => () => setTabBadge(runId, null), [runId]);
|
||||
const [context, setContext] = useState<{ tokens: number; model: string | null }>({
|
||||
tokens: 0,
|
||||
@@ -484,6 +751,16 @@ export const PaneChat = memo(function PaneChat({
|
||||
() => showTerminal(runId, sessionId, run.tool),
|
||||
[runId, sessionId, run.tool]
|
||||
);
|
||||
// The conversation on screen: an artifact's versions are rebuilt from it.
|
||||
const [convMessages, setConvMessages] = useState<TranscriptMessage[]>([]);
|
||||
// An artifact card opens in this chat's own panel, like Claude Desktop's —
|
||||
// not the workspace's Changes panel a plain file link goes to.
|
||||
const openArtifact = useCallback(
|
||||
(named: string) => {
|
||||
setPreview(resolveIn(cwd, named));
|
||||
},
|
||||
[cwd]
|
||||
);
|
||||
const openFile = useCallback(
|
||||
(named: string) => {
|
||||
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
|
||||
@@ -533,6 +810,8 @@ export const PaneChat = memo(function PaneChat({
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-prompt-drop
|
||||
// The last chat focused is where the Changes panel's comments go.
|
||||
onFocusCapture={() => notePaneFocus(runId)}
|
||||
className="relative flex min-h-0 flex-1 [container-type:inline-size]"
|
||||
style={{ "--chat-side": `${side * 100}%` } as CSSProperties}
|
||||
>
|
||||
@@ -549,6 +828,8 @@ export const PaneChat = memo(function PaneChat({
|
||||
onContext={onContext}
|
||||
onNotStarted={setNotStarted}
|
||||
onOpenFile={openFile}
|
||||
onOpenArtifact={openArtifact}
|
||||
onMessages={setConvMessages}
|
||||
/>
|
||||
{!outlineOpen && !preview && (
|
||||
<button
|
||||
@@ -573,6 +854,10 @@ export const PaneChat = memo(function PaneChat({
|
||||
menu={menu}
|
||||
onChoose={choose}
|
||||
onAnswer={answer}
|
||||
onDenyNote={denyWithNote}
|
||||
onStep={(dir) => {
|
||||
if (!stepRunMenu(runId, dir)) toast.error(t("chat.notSent"));
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (interruptRun(runId)) setMenu(null);
|
||||
else toast.error(t("chat.notSent"));
|
||||
@@ -609,6 +894,26 @@ export const PaneChat = memo(function PaneChat({
|
||||
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
|
||||
/>
|
||||
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
|
||||
{run.tool === "codex" &&
|
||||
// Codex's `/model` (then its effort) and `/permissions` are
|
||||
// numbered pickers: typed here, answered on the menu card.
|
||||
(
|
||||
[
|
||||
["/permissions", t("fields.permissionMode")],
|
||||
["/model", t("fields.model")],
|
||||
] as const
|
||||
).map(([line, label]) => (
|
||||
<button
|
||||
key={line}
|
||||
type="button"
|
||||
data-testid={`pane-chat-codex-${line.slice(1)}`}
|
||||
onClick={() => void command(line)}
|
||||
title={t("chat.codexPicker")}
|
||||
className="rounded-md px-2 py-1 text-xs text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<ContextMeter tokens={context.tokens} model={context.model} />
|
||||
{claude && (
|
||||
@@ -624,7 +929,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{working && !text ? (
|
||||
{busy && !text ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pane-chat-stop"
|
||||
@@ -669,6 +974,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
<FilePreview
|
||||
path={preview}
|
||||
scope={scope}
|
||||
messages={convMessages}
|
||||
onClose={() => setPreview(null)}
|
||||
// Beside the chat when the pane has room, over it when not.
|
||||
className="absolute inset-x-0 top-0 bottom-[calc(100%-var(--chat-area,100%))] z-30 rounded-xl border border-border [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:flex-shrink-0"
|
||||
|
||||
@@ -70,7 +70,7 @@ import {
|
||||
} from "../../lib/paneLayout";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { RunDot, type RunState } from "./ConsoleTabs";
|
||||
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
|
||||
import { CHAT_TOOLS, setSessionChat, useSessionChat } from "../../lib/chatUi";
|
||||
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
||||
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
|
||||
import { promptForPane } from "./RunSetup";
|
||||
@@ -238,7 +238,7 @@ function PaneMenu({
|
||||
const ITEM =
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||
|
||||
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
|
||||
/** Chat UI ⇄ terminal for a `CHAT_TOOLS` pane — this session's own view
|
||||
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. The
|
||||
* unsent prompt goes along: the terminal's input box into the chat's draft,
|
||||
* the draft (its files as paths) back into the input box, never sent. */
|
||||
@@ -254,7 +254,7 @@ export function showTerminal(runId: string, sessionId: string, tool?: string) {
|
||||
function ChatToggle({ run }: { run?: RunHandle }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const chat = useSessionChat(run?.sessionId);
|
||||
if (!run?.sessionId || (run.tool !== "claude" && run.tool !== "codex")) return null;
|
||||
if (!run?.sessionId || !CHAT_TOOLS.has(run.tool)) return null;
|
||||
const sessionId = run.sessionId;
|
||||
const label = chat ? t("panes.showTerminal") : t("panes.showChat");
|
||||
return (
|
||||
|
||||
@@ -308,7 +308,7 @@ export function PromptEditor({
|
||||
Promise.all([
|
||||
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
|
||||
api.ccConfig.plugins(fileCwd).catch(() => ({ plugins: [] })),
|
||||
]).then(([c, sk, pl]) => {
|
||||
// A plugin's commands and skills are addressed as `/<plugin>:<name>`; one
|
||||
// installed at both user and project scope is listed once.
|
||||
|
||||
@@ -17,9 +17,10 @@
|
||||
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
|
||||
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
||||
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
|
||||
* reads them (an image as an image) when the prompt is sent; a browser on the
|
||||
* dashboard's own machine pastes a file manager's files by their own paths
|
||||
* instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
||||
* reads them when the prompt is sent — an image as its bare path, which claude
|
||||
* shows as `[Image #n]` like a native drop; a browser on the dashboard's own
|
||||
* machine pastes a file manager's files (dropped, or copied: `file://` text)
|
||||
* by their own paths instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
||||
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
||||
* pasted into whichever of the workspace's panes was focused last.
|
||||
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
||||
@@ -64,9 +65,15 @@ import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { writeClipboard } from "../../lib/clipboard";
|
||||
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
import {
|
||||
claudeMention,
|
||||
MENU_CURSOR_ROW,
|
||||
notePaneFocus,
|
||||
notePaneOutput,
|
||||
registerPane,
|
||||
} from "../../lib/panePaste";
|
||||
import {
|
||||
claudePastes,
|
||||
droppedFilePaths,
|
||||
isLocalBrowser,
|
||||
localCandidates,
|
||||
sameFilesHere,
|
||||
@@ -153,6 +160,15 @@ const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Fi
|
||||
const hasText = (e: DragEvent) =>
|
||||
!hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain");
|
||||
|
||||
/** The `file://` paths a paste carries as text — a file manager's copy — or
|
||||
* null: its uri-list, else its plain text when every line is one. */
|
||||
function copiedFilePaths(dt: DataTransfer | null): string[] | null {
|
||||
const text = dt?.getData("text/plain") ?? "";
|
||||
if (droppedFilePaths(dt)) return droppedFilePaths(dt);
|
||||
if (!/^file:\/\//.test(text.trim())) return null;
|
||||
return droppedFilePaths({ getData: () => text } as unknown as DataTransfer);
|
||||
}
|
||||
|
||||
interface TerminalViewProps {
|
||||
runId: string;
|
||||
wsBaseUrl: string;
|
||||
@@ -168,8 +184,9 @@ interface TerminalViewProps {
|
||||
* so it is moved out of the viewport with a transform, which keeps its
|
||||
* layout size (no PTY resize; the chat still reads and answers its menus). */
|
||||
covered?: boolean;
|
||||
/** The pane's AI tool: a `claude` pane is handed a pasted or dropped file
|
||||
* as an `@` mention (`claudeMention`), any other one its plain path. */
|
||||
/** The pane's AI tool: a `claude` pane is handed a pasted or dropped image
|
||||
* as its bare path and any other file as an `@` mention (`claudePastes`),
|
||||
* any other tool its plain path. */
|
||||
tool?: string;
|
||||
}
|
||||
|
||||
@@ -241,8 +258,10 @@ export const TerminalView = memo(function TerminalView({
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
setDrop(null);
|
||||
const local = isLocalBrowser() ? droppedFilePaths(e.dataTransfer) : null;
|
||||
const text = e.dataTransfer.getData("text/plain");
|
||||
if (text) termRef.current?.paste(text + " ");
|
||||
if (local) pastePaths(local);
|
||||
else if (text) termRef.current?.paste(text + " ");
|
||||
termRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
@@ -262,6 +281,14 @@ export const TerminalView = memo(function TerminalView({
|
||||
const ctrlV = ctrlVRef.current;
|
||||
ctrlVRef.current = false;
|
||||
const files = Array.from(e.clipboardData?.files ?? []);
|
||||
// A file manager's copied files reach a browser as `file://` text only.
|
||||
const local = files.length === 0 && isLocalBrowser() ? copiedFilePaths(e.clipboardData) : null;
|
||||
if (local) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
pastePaths(local);
|
||||
return;
|
||||
}
|
||||
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -269,6 +296,19 @@ export const TerminalView = memo(function TerminalView({
|
||||
else termRef.current?.input("\x16", true);
|
||||
};
|
||||
|
||||
/** Pastes files' paths as a local terminal hands them over — paste(), not
|
||||
* a raw send: it honours bracketed-paste mode, so the CLI sees a paste
|
||||
* rather than typed keystrokes. A `claude` pane gets its images as bare
|
||||
* paths, which it shows as `[Image #n]`, and other files as `@` mentions
|
||||
* (`claudePastes`). */
|
||||
const pastePaths = (paths: string[]) => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
for (const text of tool === "claude" ? claudePastes(paths) : [paths.join(" ")])
|
||||
term.paste(text + " ");
|
||||
term.focus();
|
||||
};
|
||||
|
||||
/** Pastes the files' paths: their own (`local`, the `file://` paths a
|
||||
* browser on the dashboard's machine is handed beside the bytes) once the
|
||||
* server finds the same files there — nothing uploaded —, else the paths
|
||||
@@ -286,13 +326,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
return [];
|
||||
});
|
||||
}
|
||||
if (paths.length) {
|
||||
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
||||
// sees pasted paths rather than typed keystrokes.
|
||||
const asText = tool === "claude" ? claudeMention : (p: string) => p;
|
||||
termRef.current?.paste(paths.map(asText).join(" ") + " ");
|
||||
termRef.current?.focus();
|
||||
}
|
||||
if (paths.length) pastePaths(paths);
|
||||
// Over the server's limit (ETOOBIG): refused before the body is sent.
|
||||
const tooBig = errors
|
||||
.map((e) => e as { code?: string; maxBytes?: number } | null)
|
||||
@@ -588,6 +622,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
}
|
||||
}
|
||||
term.write(data);
|
||||
notePaneOutput(runId);
|
||||
}
|
||||
};
|
||||
sock.onclose = (e?: CloseEvent) => {
|
||||
@@ -750,6 +785,17 @@ export const TerminalView = memo(function TerminalView({
|
||||
rows.push(row);
|
||||
}
|
||||
return { rows, cols: term.cols };
|
||||
},
|
||||
// Plain text of the rows on screen, dim cells kept (a status line's
|
||||
// `esc to interrupt` is dim): the chat's busy read.
|
||||
() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return [];
|
||||
const buf = term.buffer.active;
|
||||
const rows: string[] = [];
|
||||
for (let y = 0; y < term.rows; y++)
|
||||
rows.push(buf.getLine(buf.baseY + y)?.translateToString(true) ?? "");
|
||||
return rows;
|
||||
}
|
||||
),
|
||||
[runId]
|
||||
|
||||
@@ -31,6 +31,10 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
gitRawUrl: () => "",
|
||||
},
|
||||
artifacts: {
|
||||
pinFile: vi.fn().mockResolvedValue({ item: { id: 1 } }),
|
||||
uploadFile: vi.fn().mockResolvedValue({ item: { id: 2 } }),
|
||||
},
|
||||
sessions: {
|
||||
readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}`,
|
||||
readFile: vi.fn((_id: string, p: string) =>
|
||||
@@ -75,7 +79,11 @@ const scope = { laneId: 4, laneCwd: "/w", cwd: "/w", sessionId: "s1" };
|
||||
describe("FilePreview", () => {
|
||||
it("reads a workspace file through the lane", async () => {
|
||||
render(<FilePreview path="/w/src/a.ts" scope={scope} onClose={() => {}} />);
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
|
||||
});
|
||||
|
||||
@@ -89,7 +97,11 @@ describe("FilePreview", () => {
|
||||
it("reads Windows paths the same way", async () => {
|
||||
const win = { laneId: 4, laneCwd: "D:\\w", cwd: "D:\\w", sessionId: "s1" };
|
||||
render(<FilePreview path={"D:\\w\\src\\a.ts"} scope={win} onClose={() => {}} />);
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
|
||||
cleanup();
|
||||
const tool = "C:\\Users\\k\\.claude\\projects\\D--w\\s1\\tool-results\\b.txt";
|
||||
@@ -144,7 +156,11 @@ describe("FileListModal", () => {
|
||||
);
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "a.ts" }));
|
||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,3 +188,77 @@ describe("SkillListModal", () => {
|
||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the artifact view", () => {
|
||||
const html = (lines: string[]) =>
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValueOnce({
|
||||
available: true,
|
||||
path: "page.html",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines,
|
||||
} as never);
|
||||
|
||||
it("runs a page in a sandbox with scripts but no same origin, and shows its code", async () => {
|
||||
html(["<button>go</button>"]);
|
||||
render(<FilePreview path="/w/page.html" scope={scope} onClose={() => {}} />);
|
||||
const frame = (await screen.findByTestId("artifact-frame")) as HTMLIFrameElement;
|
||||
expect(frame.getAttribute("sandbox")).toContain("allow-scripts");
|
||||
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
|
||||
expect(frame.getAttribute("srcdoc")).toContain("<button>go</button>");
|
||||
fireEvent.click(screen.getByTestId("artifact-tab-code"));
|
||||
expect(screen.queryByTestId("artifact-frame")).toBeNull();
|
||||
expect(
|
||||
await screen.findByText(
|
||||
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("<button>go</button>")
|
||||
)
|
||||
).toBeTruthy();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("steps back to an older version the conversation wrote", async () => {
|
||||
html(["<p>two</p>"]);
|
||||
const tool = (id: string, input: Record<string, unknown>, name = "Write") => ({
|
||||
type: "assistant" as const,
|
||||
timestamp: null,
|
||||
content: [{ type: "tool_use" as const, id, name, input }],
|
||||
});
|
||||
const messages = [
|
||||
tool("1", { file_path: "/w/page.html", content: "<p>one</p>" }),
|
||||
tool("2", { file_path: "/w/page.html", old_string: "one", new_string: "two" }, "Edit"),
|
||||
];
|
||||
render(
|
||||
<FilePreview
|
||||
path="/w/page.html"
|
||||
scope={scope}
|
||||
onClose={() => {}}
|
||||
messages={messages as never}
|
||||
/>
|
||||
);
|
||||
const versions = await screen.findByTestId("artifact-versions");
|
||||
expect(versions.textContent).toContain("2");
|
||||
expect(
|
||||
((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc")
|
||||
).toContain("two");
|
||||
fireEvent.click(versions.querySelector("button")!);
|
||||
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("saves a workspace file to the Artifacts library by its path", async () => {
|
||||
html(["<p>x</p>"]);
|
||||
render(<FilePreview path="/w/page.html" scope={scope} onClose={() => {}} />);
|
||||
await screen.findByTestId("artifact-frame");
|
||||
fireEvent.click(screen.getByTestId("artifact-share"));
|
||||
await vi.waitFor(() =>
|
||||
expect(api.artifacts.pinFile).toHaveBeenCalledWith({
|
||||
laneId: 4,
|
||||
path: "page.html",
|
||||
title: "page.html",
|
||||
sessionId: "s1",
|
||||
})
|
||||
);
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
* activity panel, collapsed into a file button with the output count, whose
|
||||
* files open a preview in its place; a file in a turn's "Edited N files" card
|
||||
* opens in the Workspace's Changes panel when it is in the pane's folder; and
|
||||
* Stop in Send's place while the main agent works, pressing Esc in the pane;
|
||||
* Stop in Send's place while the main agent works (its hooks, or the pane's
|
||||
* own busy line / fresh output), pressing Esc in the pane;
|
||||
* and the menu the CLI asks with, shown as buttons that press its keys, a
|
||||
* message sent meanwhile typed as its "Type something" answer.
|
||||
* message sent meanwhile typed as its "Type something" answer; a Codex
|
||||
* terminal's Model / Permission mode buttons typing its own pickers.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -22,11 +24,14 @@ import {
|
||||
answerRunMenu,
|
||||
chooseRunOption,
|
||||
interruptRun,
|
||||
onPaneOutput,
|
||||
readRunBusy,
|
||||
readRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
} from "../../../lib/panePaste";
|
||||
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
|
||||
vi.mock("../../conversation/ConversationView", () => ({
|
||||
// Stands in for a turn's "Edited N files" card: a click names a file.
|
||||
@@ -51,8 +56,12 @@ vi.mock("../../../lib/panePaste", () => ({
|
||||
submitToRun: vi.fn(() => true),
|
||||
interruptRun: vi.fn(() => true),
|
||||
readRunMenu: vi.fn(() => null),
|
||||
readRunBusy: vi.fn(() => false),
|
||||
chooseRunOption: vi.fn(() => true),
|
||||
answerRunMenu: vi.fn(() => true),
|
||||
stepRunMenu: vi.fn(() => true),
|
||||
notePaneFocus: vi.fn(),
|
||||
onPaneOutput: vi.fn(() => () => {}),
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -212,6 +221,54 @@ describe("PaneChat — Stop", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("shows Stop while the pane's own busy line shows, with no hook saying so", async () => {
|
||||
// omp, Cursor or Codex without its hooks: only the terminal knows.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.mocked(readRunBusy).mockReturnValue(true);
|
||||
try {
|
||||
renderChat();
|
||||
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||
expect(readRunBusy).toHaveBeenCalledWith("agenthub-lane-1", "claude");
|
||||
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("counts a tool with no known busy line as working while its pane draws", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.mocked(readRunBusy).mockReturnValue(null);
|
||||
const fns: (() => void)[] = [];
|
||||
vi.mocked(onPaneOutput).mockImplementation((_runId, fn) => {
|
||||
fns.push(fn);
|
||||
return () => {};
|
||||
});
|
||||
try {
|
||||
renderChat();
|
||||
await waitFor(() => expect(fns.length).toBeGreaterThan(0));
|
||||
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
|
||||
fns.forEach((fn) => fn());
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
});
|
||||
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||
// Quiet for longer than OUTPUT_BUSY_MS: at rest again.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2800);
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||
vi.mocked(onPaneOutput).mockImplementation(() => () => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Send for a session at rest", async () => {
|
||||
// With session_id the server answers EVERY agent of the session (its
|
||||
// status filter does not apply): the main agent is there, waiting.
|
||||
@@ -553,4 +610,103 @@ describe("PaneChat — a menu the CLI asks with", () => {
|
||||
vi.mocked(readRunMenu).mockReturnValue(null);
|
||||
}
|
||||
});
|
||||
|
||||
it("re-reads its transcript when the pane draws, at most once a second", async () => {
|
||||
// Every listener the chat put on the pane (the re-read, the busy clock).
|
||||
const fns: (() => void)[] = [];
|
||||
const drew = () => fns.forEach((fn) => fn());
|
||||
vi.mocked(onPaneOutput).mockImplementation((_runId, fn) => {
|
||||
fns.push(fn);
|
||||
return () => {};
|
||||
});
|
||||
const seen: unknown[] = [];
|
||||
const off = eventBus.subscribe((m) => {
|
||||
if (m.type === "transcript_changed") seen.push(m.data);
|
||||
});
|
||||
try {
|
||||
renderChat();
|
||||
await waitFor(() => expect(fns.length).toBeGreaterThan(0));
|
||||
vi.useFakeTimers();
|
||||
drew();
|
||||
drew();
|
||||
drew();
|
||||
expect(seen).toEqual([{ session_id: "s1" }]);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
});
|
||||
expect(seen).toHaveLength(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
off();
|
||||
vi.mocked(onPaneOutput).mockImplementation(() => () => {});
|
||||
}
|
||||
});
|
||||
|
||||
const PERMISSION: PaneMenu = {
|
||||
question: "Do you want to proceed?",
|
||||
detail: ["Bash command", "npm test", "Run the tests"],
|
||||
options: [
|
||||
{ n: 1, label: "Yes", hint: "", checked: null },
|
||||
{ n: 2, label: "Yes, and don't ask again for npm test", hint: "", checked: null },
|
||||
{ n: 3, label: "No, and tell Claude what to do differently (esc)", hint: "", checked: null },
|
||||
],
|
||||
cursor: 0,
|
||||
multi: false,
|
||||
typeAt: -1,
|
||||
};
|
||||
|
||||
it("lays a permission prompt out as a card, each answer its own key", async () => {
|
||||
vi.mocked(readRunMenu).mockReturnValue(PERMISSION);
|
||||
vi.mocked(chooseRunOption).mockClear();
|
||||
try {
|
||||
renderChat();
|
||||
const card = await screen.findByTestId("pane-chat-permission");
|
||||
expect(card).toHaveTextContent("Bash command");
|
||||
expect(card).toHaveTextContent("npm test");
|
||||
expect(card).toHaveTextContent("Do you want to proceed?");
|
||||
const options = screen.getAllByTestId("pane-chat-permission-option");
|
||||
// The key hint is dropped from the label.
|
||||
expect(options.map((o) => o.textContent)).toEqual([
|
||||
"Yes",
|
||||
"Yes, and don't ask again for npm test",
|
||||
"No, and tell Claude what to do differently",
|
||||
]);
|
||||
fireEvent.click(options[1]!);
|
||||
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", PERMISSION, 1);
|
||||
} finally {
|
||||
vi.mocked(readRunMenu).mockReturnValue(null);
|
||||
}
|
||||
});
|
||||
|
||||
it("denies with a note: picks the No row, then sends the note as the next message", async () => {
|
||||
vi.mocked(readRunMenu).mockReturnValue(PERMISSION);
|
||||
vi.mocked(chooseRunOption).mockClear();
|
||||
vi.mocked(submitToRun).mockClear();
|
||||
try {
|
||||
renderChat();
|
||||
fireEvent.click(await screen.findByTestId("pane-chat-permission-tell"));
|
||||
const field = screen.getByTestId("pane-chat-permission-note");
|
||||
fireEvent.change(field, { target: { value: "use pnpm" } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", PERMISSION, 2);
|
||||
await waitFor(() => expect(submitToRun).toHaveBeenCalled(), { timeout: 2000 });
|
||||
expect(vi.mocked(submitToRun).mock.calls[0]![1]).toContain("use pnpm");
|
||||
} finally {
|
||||
vi.mocked(readRunMenu).mockReturnValue(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaneChat — a Codex terminal", () => {
|
||||
it("types /model and /permissions, whose pickers come back as menu cards", () => {
|
||||
renderChat({ tool: "codex" });
|
||||
fireEvent.click(screen.getByTestId("pane-chat-codex-model"));
|
||||
fireEvent.click(screen.getByTestId("pane-chat-codex-permissions"));
|
||||
expect(vi.mocked(submitToRun).mock.calls.map((c) => c[1])).toEqual(["/model", "/permissions"]);
|
||||
});
|
||||
|
||||
it("offers neither to a Claude pane", () => {
|
||||
renderChat();
|
||||
expect(screen.queryByTestId("pane-chat-codex-model")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
* @description Settings → Account (server mode, any signed-in role): the
|
||||
* account's own information (`GET /api/me/account` — username, role, member
|
||||
* since, workspaces, sessions, active sign-ins, last sign-in), its avatar
|
||||
* (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB; the
|
||||
* sidebar shows it at once), its display name
|
||||
* (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB — or
|
||||
* an https link the server downloads, `POST /api/me/avatar/url`; the sidebar
|
||||
* shows it at once), its display name
|
||||
* and email (`PATCH /api/me/account`), a password change that asks for the
|
||||
* current one (`POST /api/me/password` — this browser stays signed in, every
|
||||
* current one and passes every rule of `lib/passwordRules.ts`, ticked as it is
|
||||
* typed (`POST /api/me/password` — this browser stays signed in, every
|
||||
* other sign-in ends) and "Sign out other devices" (`POST
|
||||
* /api/me/sign-out-others`, behind `confirmDialog`). Role and state are an
|
||||
* admin's to change, on the Users page — never here.
|
||||
@@ -23,7 +25,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Camera, LogOut, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { Camera, Link2, LogOut, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { api, type MyAccount } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
@@ -32,6 +34,8 @@ import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useAuth } from "../AuthGate";
|
||||
import { ROLE_BADGE } from "../users/UserEditDialog";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
import { PasswordRules } from "../users/PasswordRules";
|
||||
import { passwordProblems } from "../../lib/passwordRules";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
type ServerAccount = Extract<MyAccount, { mode: "server" }>;
|
||||
@@ -110,18 +114,27 @@ function ProfileCard({
|
||||
const shown = user.displayName || user.username;
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// null = the link field is closed.
|
||||
const [link, setLink] = useState<string | null>(null);
|
||||
|
||||
const run = async (op: () => Promise<MyAccount>, done: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
onSaved(await op());
|
||||
toast.success(done);
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast.error(errText(t, e));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const applyLink = async () => {
|
||||
const url = link?.trim();
|
||||
if (!url) return;
|
||||
if (await run(() => api.me.setAvatarFromUrl(url), t("account.avatar.saved"))) setLink(null);
|
||||
};
|
||||
const pick = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = ""; // the same file can be picked again
|
||||
@@ -150,6 +163,18 @@ function ProfileCard({
|
||||
>
|
||||
<Camera className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-ghost h-6 w-6 justify-center p-0 ${link !== null ? "text-accent" : ""}`}
|
||||
onClick={() => setLink((v) => (v === null ? "" : null))}
|
||||
disabled={busy}
|
||||
title={t("account.avatar.fromUrl")}
|
||||
aria-label={t("account.avatar.fromUrl")}
|
||||
aria-pressed={link !== null}
|
||||
data-testid="account-avatar-url-toggle"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{user.avatarAt && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -187,6 +212,36 @@ function ProfileCard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{link !== null && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
className="input w-full text-sm"
|
||||
value={link}
|
||||
placeholder={t("account.avatar.urlPlaceholder")}
|
||||
aria-label={t("account.avatar.fromUrl")}
|
||||
autoFocus
|
||||
disabled={busy}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void applyLink();
|
||||
} else if (e.key === "Escape") setLink(null);
|
||||
}}
|
||||
data-testid="account-avatar-url-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary flex-shrink-0 text-sm"
|
||||
onClick={() => void applyLink()}
|
||||
disabled={busy || !link.trim()}
|
||||
data-testid="account-avatar-url-apply"
|
||||
>
|
||||
{t("account.avatar.urlApply")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<dl className="mt-4 grid grid-cols-2 gap-3 border-t border-border pt-4 text-xs sm:grid-cols-5">
|
||||
<Stat label={t("account.memberSince")}>
|
||||
{user.createdAt ? formatDateTime(user.createdAt) : "—"}
|
||||
@@ -283,13 +338,17 @@ function ProfileForm({
|
||||
|
||||
function PasswordForm() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { state } = useAuth();
|
||||
const username = state.mode === "server" ? state.user?.username : undefined;
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [again, setAgain] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mismatch = again.length > 0 && again !== next;
|
||||
const ready = current.length > 0 && next.length > 0 && again === next && !busy;
|
||||
// The account's own password: every rule (server/lib/auth.js decides too).
|
||||
const strong = passwordProblems(next, username).length === 0;
|
||||
const ready = current.length > 0 && strong && again === next && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
@@ -331,7 +390,7 @@ function PasswordForm() {
|
||||
aria-label={t("account.currentPassword")}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("account.newPassword")} hint={t("users:passwordHint")}>
|
||||
<Field label={t("account.newPassword")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
@@ -352,6 +411,9 @@ function PasswordForm() {
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<PasswordRules password={next} username={username} />
|
||||
</div>
|
||||
{(mismatch || error) && (
|
||||
<p role="alert" className="mt-2 text-xs text-status-danger">
|
||||
{error ?? t("account.mismatch")}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* `#terminal-font`, with a preview line); **Sessions** (`#sessions-view`) —
|
||||
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
|
||||
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
||||
* `#workspace-tabs`); then the
|
||||
* `#workspace-tabs`), and whether a Markdown file opens as Preview (the
|
||||
* default) or Raw (`lib/markdownView.ts`, `#markdown-view`); then the
|
||||
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
||||
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
type TerminalFont,
|
||||
} from "../../lib/terminalFont";
|
||||
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
|
||||
import { readMarkdownPreview, writeMarkdownPreview } from "../../lib/markdownView";
|
||||
import {
|
||||
TERMINAL_FONT_SIZES,
|
||||
UI_FONT_SIZES,
|
||||
@@ -97,6 +99,7 @@ export function AppearanceSettings() {
|
||||
const [termSize, setTermSize] = useState(readTerminalFontSize);
|
||||
const [uiSize, setUiSize] = useState(readUiFontSize);
|
||||
const [allTabs, setAllTabs] = useState(readAllTabs);
|
||||
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
|
||||
const pickTermFont = (font: TerminalFont) => {
|
||||
writeTerminalFont(font);
|
||||
setTermFont(font);
|
||||
@@ -224,6 +227,25 @@ export function AppearanceSettings() {
|
||||
setAllTabs(on);
|
||||
}}
|
||||
/>
|
||||
<SettingsRow
|
||||
id="markdown-view"
|
||||
label={t("markdownView.label")}
|
||||
description={t("markdownView.description")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("markdownView.label")}
|
||||
value={mdPreview ? "preview" : "raw"}
|
||||
onChange={(v) => {
|
||||
writeMarkdownPreview(v === "preview");
|
||||
setMdPreview(v === "preview");
|
||||
}}
|
||||
options={[
|
||||
{ value: "preview" as const, label: t("markdownView.preview") },
|
||||
{ value: "raw" as const, label: t("markdownView.raw") },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @file OmpSharingSettings — Settings → AI tools → Oh My Pi login sharing: the
|
||||
* admin's server-wide switch that lets every member's omp pane run on the
|
||||
* HOST account's omp login through omp's own auth broker + gateway, without
|
||||
* any member holding a credential (`GET/PUT /api/ai-tools/omp-sharing`,
|
||||
* `server/lib/omp-share.js`). Renders nothing outside server mode with
|
||||
* per-user accounts (S3), or for anyone but an admin.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type OmpSharingState } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function OmpSharingSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { isAdmin, local } = usePermissions();
|
||||
const [state, setState] = useState<OmpSharingState | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (local || !isAdmin) return;
|
||||
api.aiTools
|
||||
.ompSharing()
|
||||
.then(setState)
|
||||
.catch(() => setState(null));
|
||||
}, [local, isAdmin]);
|
||||
|
||||
if (local || !isAdmin || !state?.available) return null;
|
||||
const error = state.broker.error || state.gateway.error;
|
||||
const status = !state.enabled
|
||||
? null
|
||||
: !state.installed
|
||||
? t("ompSharing.notInstalled")
|
||||
: state.broker.running && state.gateway.running
|
||||
? t("ompSharing.running")
|
||||
: error
|
||||
? t("ompSharing.failed", { error })
|
||||
: t("ompSharing.starting");
|
||||
return (
|
||||
<SettingsSection id="omp-sharing" title={t("ompSharing.title")}>
|
||||
<SettingsSwitchRow
|
||||
label={t("ompSharing.switch")}
|
||||
description={t("ompSharing.hint")}
|
||||
checked={state.enabled}
|
||||
disabled={busy}
|
||||
onChange={async (on) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setState(await api.aiTools.setOmpSharing(on));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{status && (
|
||||
<p data-testid="omp-sharing-status" className="text-xs text-fg-muted">
|
||||
{status}
|
||||
</p>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
* @file AccountSettings.test.tsx
|
||||
* @description Settings → Account: shows the account's own information, saves
|
||||
* only a changed display name/email, refuses a password change whose two new
|
||||
* passwords differ, sends the current + new password, asks before signing
|
||||
* the other devices out, and uploads/removes the avatar.
|
||||
* passwords differ, keeps a password that misses a rule from being sent,
|
||||
* sends the current + new password, asks before signing
|
||||
* the other devices out, and uploads/removes the avatar or sets it from a link.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -17,6 +18,7 @@ const me = vi.hoisted(() => ({
|
||||
changePassword: vi.fn(),
|
||||
signOutOthers: vi.fn(),
|
||||
setAvatar: vi.fn(),
|
||||
setAvatarFromUrl: vi.fn(),
|
||||
removeAvatar: vi.fn(),
|
||||
avatarUrl: (at?: string | null) => (at ? `/api/me/avatar?v=${at}` : null),
|
||||
}));
|
||||
@@ -76,6 +78,22 @@ describe("AccountSettings", () => {
|
||||
expect(await screen.findByTestId("user-avatar-initials")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sets the avatar from a link and closes the field", async () => {
|
||||
const ue = userEvent.setup();
|
||||
const withAvatar = { ...account, user: { ...account.user, avatarAt: "2026-10-21T00:00:00Z" } };
|
||||
me.setAvatarFromUrl.mockResolvedValue(withAvatar);
|
||||
render(<AccountSettings />);
|
||||
await screen.findByTestId("account-card");
|
||||
expect(screen.queryByTestId("account-avatar-url-input")).toBeNull();
|
||||
await ue.click(screen.getByTestId("account-avatar-url-toggle"));
|
||||
await ue.type(screen.getByTestId("account-avatar-url-input"), " https://x.vn/me.png {Enter}");
|
||||
await waitFor(() => expect(me.setAvatarFromUrl).toHaveBeenCalledWith("https://x.vn/me.png"));
|
||||
expect((await screen.findByTestId("user-avatar-img")).getAttribute("src")).toBe(
|
||||
"/api/me/avatar?v=2026-10-21T00:00:00Z"
|
||||
);
|
||||
expect(screen.queryByTestId("account-avatar-url-input")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the account's own information", async () => {
|
||||
render(<AccountSettings />);
|
||||
const card = await screen.findByTestId("account-card");
|
||||
@@ -114,9 +132,21 @@ describe("AccountSettings", () => {
|
||||
await ue.type(screen.getByLabelText("Current password"), "old-pass-1");
|
||||
await ue.type(screen.getByLabelText("New password"), "new-pass-123");
|
||||
await ue.type(screen.getByLabelText("Confirm new password"), "new-pass-123");
|
||||
// No upper-case letter: the checklist says so and the button stays off.
|
||||
expect(
|
||||
screen
|
||||
.getByTestId("password-rules")
|
||||
.querySelector('[data-rule="upper"]')
|
||||
?.getAttribute("data-ok")
|
||||
).toBe("false");
|
||||
expect(screen.getByRole("button", { name: "Change password" })).toBeDisabled();
|
||||
await ue.clear(screen.getByLabelText("New password"));
|
||||
await ue.clear(screen.getByLabelText("Confirm new password"));
|
||||
await ue.type(screen.getByLabelText("New password"), "New-pass-123");
|
||||
await ue.type(screen.getByLabelText("Confirm new password"), "New-pass-123");
|
||||
await ue.click(screen.getByRole("button", { name: "Change password" }));
|
||||
await waitFor(() =>
|
||||
expect(me.changePassword).toHaveBeenCalledWith("old-pass-1", "new-pass-123")
|
||||
expect(me.changePassword).toHaveBeenCalledWith("old-pass-1", "New-pass-123")
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @file Tests for OmpSharingSettings — the admin's Oh My Pi login-sharing
|
||||
* switch in Settings → AI tools: hidden in local mode, for a non-admin and
|
||||
* outside S3, and a toggle saves through PUT /api/ai-tools/omp-sharing and
|
||||
* shows the daemons' state.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import "../../../i18n";
|
||||
import { OmpSharingSettings } from "../OmpSharingSettings";
|
||||
import { api, type OmpSharingState } from "../../../lib/api";
|
||||
|
||||
const perms = vi.hoisted(() => ({ isAdmin: true, local: false }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { aiTools: { ompSharing: vi.fn(), setOmpSharing: vi.fn() } },
|
||||
}));
|
||||
|
||||
const get = vi.mocked(api.aiTools.ompSharing);
|
||||
const set = vi.mocked(api.aiTools.setOmpSharing);
|
||||
const off: OmpSharingState = {
|
||||
available: true,
|
||||
enabled: false,
|
||||
installed: true,
|
||||
broker: { running: false, error: null },
|
||||
gateway: { running: false, error: null },
|
||||
};
|
||||
|
||||
describe("OmpSharingSettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
perms.isAdmin = true;
|
||||
perms.local = false;
|
||||
});
|
||||
|
||||
it("lets an admin turn sharing on and shows it running", async () => {
|
||||
get.mockResolvedValue(off);
|
||||
set.mockResolvedValue({
|
||||
...off,
|
||||
enabled: true,
|
||||
broker: { running: true },
|
||||
gateway: { running: true, port: 4827 },
|
||||
});
|
||||
render(<OmpSharingSettings />);
|
||||
const sw = await screen.findByRole("switch", { name: "Share this server's Oh My Pi login" });
|
||||
fireEvent.click(sw);
|
||||
await waitFor(() => expect(set).toHaveBeenCalledWith(true));
|
||||
expect(await screen.findByTestId("omp-sharing-status")).toHaveTextContent("Running");
|
||||
});
|
||||
|
||||
it("names a daemon's failure", async () => {
|
||||
get.mockResolvedValue({
|
||||
...off,
|
||||
enabled: true,
|
||||
gateway: { running: false, error: "port 4827 is taken" },
|
||||
});
|
||||
render(<OmpSharingSettings />);
|
||||
expect(await screen.findByTestId("omp-sharing-status")).toHaveTextContent("port 4827 is taken");
|
||||
});
|
||||
|
||||
it("renders nothing outside S3, for a non-admin or in local mode", async () => {
|
||||
get.mockResolvedValue({ ...off, available: false });
|
||||
const r = render(<OmpSharingSettings />);
|
||||
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
expect(r.container).toBeEmptyDOMElement();
|
||||
r.unmount();
|
||||
perms.isAdmin = false;
|
||||
const r2 = render(<OmpSharingSettings />);
|
||||
expect(r2.container).toBeEmptyDOMElement();
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @file PasswordRules.tsx
|
||||
* @description The checklist under a "new password" field the account picks
|
||||
* for itself (Settings → Account, the first-login password change, an admin's
|
||||
* own password on the Users page): each rule of `lib/passwordRules.ts`, ticked
|
||||
* as the password typed passes it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Circle } from "lucide-react";
|
||||
import { PASSWORD_MIN_LENGTH, PASSWORD_RULES, passwordProblems } from "../../lib/passwordRules";
|
||||
|
||||
export function PasswordRules({ password, username }: { password: string; username?: string }) {
|
||||
const { t } = useTranslation("users");
|
||||
const failed = new Set(passwordProblems(password, username));
|
||||
return (
|
||||
<ul className="grid gap-x-4 gap-y-1 text-xs sm:grid-cols-2" data-testid="password-rules">
|
||||
{PASSWORD_RULES.map((rule) => {
|
||||
const ok = password.length > 0 && !failed.has(rule);
|
||||
return (
|
||||
<li
|
||||
key={rule}
|
||||
className={`flex items-center gap-1.5 ${ok ? "text-status-success" : "text-fg-muted"}`}
|
||||
data-rule={rule}
|
||||
data-ok={ok}
|
||||
>
|
||||
{ok ? (
|
||||
<Check className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
) : (
|
||||
<Circle className="h-3 w-3 flex-shrink-0" />
|
||||
)}
|
||||
{t(`passwordRules.${rule}`, { min: PASSWORD_MIN_LENGTH })}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
* @file UserEditDialog.tsx
|
||||
* @description The Users page's ONE account editor (admin only): profile
|
||||
* (display name, email), access (role, active/disabled — neither for your own
|
||||
* account), a new password (typed or generated, copyable), the workspaces the
|
||||
* account), a new password (typed or generated, copyable — someone else's
|
||||
* may be anything and makes them choose their own at next sign-in; your own
|
||||
* must pass every rule of `lib/passwordRules.ts`), the workspaces the
|
||||
* account owns (remove, add from every workspace), and "sign out everywhere".
|
||||
* Every change is staged and sent as ONE `PATCH /api/users/:id` carrying only
|
||||
* what changed, confirmed with the admin's OWN password (`currentPassword`) —
|
||||
@@ -20,6 +22,8 @@ import type { Lane } from "../../lib/types";
|
||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { passwordProblems } from "../../lib/passwordRules";
|
||||
import { PasswordRules } from "./PasswordRules";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { Switch } from "../Switch";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
@@ -34,11 +38,15 @@ export const ROLE_BADGE: Record<Role, string> = {
|
||||
};
|
||||
|
||||
/** A random password of `n` characters, no look-alikes (0/O, 1/l/I). */
|
||||
export function generatePassword(n = 16): string {
|
||||
export function generatePassword(n = 16, username?: string): string {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789-_!@#%";
|
||||
const bytes = new Uint32Array(n);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => chars[b % chars.length]).join("");
|
||||
// Drawn again until it passes every rule, so it also fits an own password.
|
||||
for (;;) {
|
||||
crypto.getRandomValues(bytes);
|
||||
const out = Array.from(bytes, (b) => chars[b % chars.length]).join("");
|
||||
if (passwordProblems(out, username).length === 0) return out;
|
||||
}
|
||||
}
|
||||
|
||||
type OwnedWorkspace = { id: number; title: string; cwd: string };
|
||||
@@ -110,8 +118,11 @@ export function UserEditDialog({
|
||||
if (removed.size) patch.removeWorkspaces = [...removed];
|
||||
if (signOut) patch.signOut = true;
|
||||
const changed = Object.keys(patch).length > 0;
|
||||
// No minimum length (server/lib/auth.js) — a blank field keeps the password.
|
||||
const ready = changed && current.length > 0 && !busy;
|
||||
// A blank field keeps the password. Someone else's may be anything (they
|
||||
// choose their own at next sign-in); your own must pass every rule.
|
||||
const weakOwn =
|
||||
self && password.length > 0 && passwordProblems(password, user.username).length > 0;
|
||||
const ready = changed && current.length > 0 && !weakOwn && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
@@ -309,7 +320,10 @@ export function UserEditDialog({
|
||||
</Section>
|
||||
|
||||
<Section title={t("edit.password")}>
|
||||
<Field label={t("newPassword")} hint={t("edit.passwordHint")}>
|
||||
<Field
|
||||
label={t("newPassword")}
|
||||
hint={self ? t("edit.ownPasswordHint") : t("edit.passwordHint")}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
@@ -343,7 +357,7 @@ export function UserEditDialog({
|
||||
type="button"
|
||||
className="btn-ghost whitespace-nowrap text-xs"
|
||||
onClick={() => {
|
||||
setPassword(generatePassword());
|
||||
setPassword(generatePassword(16, user.username));
|
||||
setShowPassword(true);
|
||||
}}
|
||||
>
|
||||
@@ -351,6 +365,11 @@ export function UserEditDialog({
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
{self && password.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<PasswordRules password={password} username={user.username} />
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t("workspaces")}>
|
||||
|
||||
@@ -70,7 +70,8 @@
|
||||
"form.save": "Add",
|
||||
"form.cancel": "Cancel",
|
||||
"added": "Added",
|
||||
"files": "{{count}} files",
|
||||
"files_one": "{{count}} file",
|
||||
"files_other": "{{count}} files",
|
||||
"close": "Close",
|
||||
"fromSession": "From a session",
|
||||
"form.saveEdit": "Save",
|
||||
@@ -116,6 +117,20 @@
|
||||
"edit.description": "Description",
|
||||
"previewFailed": "Could not load this file.",
|
||||
"truncated": "Showing the first 1 MB — download the file to see all of it.",
|
||||
"section.hub.own": "Shared by me",
|
||||
"section.hub.ownHint": "Yours, visible to every user",
|
||||
"section.hub.others": "Shared by others",
|
||||
"section.hub.othersHint": "What other users shared — install what you need",
|
||||
"section.mine.own": "Mine",
|
||||
"section.mine.ownHint": "What you added",
|
||||
"section.mine.others": "Installed from others",
|
||||
"section.mine.othersHint": "Shared by other users, installed in your account",
|
||||
"section.all.others": "Shared by others",
|
||||
"section.all.othersHint": "What other users shared",
|
||||
"section.all.own": "Mine",
|
||||
"section.all.ownHint": "What you added, shared or not",
|
||||
"pages": "Pages",
|
||||
"section.empty": "Nothing here yet.",
|
||||
"tab.all": "All",
|
||||
"installedBadge": "Installed",
|
||||
"installsTitle": "Installs",
|
||||
@@ -125,10 +140,11 @@
|
||||
"installsPluginWhere": "claude plugin install --scope user",
|
||||
"installsUnrecorded": "{{count}} earlier installs were counted before details were kept",
|
||||
"installsNoneOfYours": "You have not installed it — only your own installs are listed.",
|
||||
"reinstall": "Reinstall",
|
||||
"reinstallHint": "Write this version over your installed copy",
|
||||
"reinstallTitle": "Reinstall “{{title}}”?",
|
||||
"reinstallBody": "Your installed ~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/ are replaced with this version. The current copy is moved to ~/.claude/agenthub-replaced-skills/, not deleted.",
|
||||
"uninstall": "Uninstall",
|
||||
"uninstallTitle": "Uninstall “{{title}}”?",
|
||||
"uninstallSkillBody": "~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/ are moved to ~/.claude/agenthub-replaced-skills/, not deleted. Sessions already open keep it until they reload.",
|
||||
"uninstallPluginBody": "Runs claude plugin uninstall {{name}} for your account; the plugin's data folder is kept. Open sessions keep it until /reload-plugins.",
|
||||
"uninstalled": "Uninstalled {{name}}",
|
||||
"deleteBodyShared": "It is shared, so it also disappears from the Hub for everyone. To keep it but stop sharing, use Unshare instead. Anything already installed from it stays installed.",
|
||||
"deleteBodyKeep": "It leaves your library but stays in the Hub, because it is shared. To take it off the Hub too, use Remove from Hub afterwards (or Unshare first).",
|
||||
"deleteKept": "Removed from your library — still in the Hub",
|
||||
@@ -138,10 +154,34 @@
|
||||
"hubRemoveTitle": "Remove “{{title}}” from the Hub?",
|
||||
"hubRemoveBody": "It is no longer in your library, so it is deleted for good. Anything already installed from it stays installed.",
|
||||
"edit.otherFiles": "Its other {{count}} file(s) are kept as they are.",
|
||||
"edit.reinstallNote": "Your installed copy keeps the old version until you Reinstall it.",
|
||||
"edit.reinstallNote": "Your installed copy keeps the old version until you uninstall it and install it again.",
|
||||
"preview.instructions": "Instructions",
|
||||
"preview.frontmatter": "Frontmatter (SKILL.md header)",
|
||||
"preview.whenUsed": "When Claude uses it",
|
||||
"preview.noDescription": "No description",
|
||||
"preview.installsAs": "Installs as ~/.claude/skills/{{slug}}/ and ~/.agents/skills/{{slug}}/"
|
||||
"preview.installsAs": "Installs as ~/.claude/skills/{{slug}}/ and ~/.agents/skills/{{slug}}/",
|
||||
"blurb.skill": "Reusable instructions Claude follows",
|
||||
"blurb.plugin": "Bundles of skills, commands and hooks",
|
||||
"blurb.page": "Pages your sessions published on claude.ai",
|
||||
"blurb.file": "Documents and outputs worth keeping",
|
||||
"clearSearch": "Clear search",
|
||||
"categories": "Categories",
|
||||
"seeAll": "See all",
|
||||
"results_one": "{{count}} result",
|
||||
"results_other": "{{count}} results",
|
||||
"empty.title": "Your library is empty",
|
||||
"empty.hubTitle": "The Hub is quiet",
|
||||
"empty.searchTitle": "No matches",
|
||||
"empty.search": "Nothing matches this search or category — try other words, or pick All.",
|
||||
"details": "Details",
|
||||
"more": "More actions",
|
||||
"about": "About",
|
||||
"stat.kind": "Type",
|
||||
"stat.installs": "Installs",
|
||||
"stat.files": "Files",
|
||||
"stat.size": "Size",
|
||||
"stat.updated": "Updated",
|
||||
"detail.runAs": "Run it as",
|
||||
"detail.market": "Marketplace",
|
||||
"detail.added": "Added {{date}}"
|
||||
}
|
||||
|
||||
@@ -198,7 +198,15 @@
|
||||
"tooMany": "Too many failed attempts — try again in a few minutes",
|
||||
"logout": "Sign out",
|
||||
"noAccount": "No account yet? Ask your administrator to create one.",
|
||||
"signedInAs": "Signed in as {{name}} ({{role}})"
|
||||
"signedInAs": "Signed in as {{name}} ({{role}})",
|
||||
"mustChangeTitle": "Choose your own password",
|
||||
"mustChangeSubtitle": "Your administrator gave you a starting password. Choose your own before you continue.",
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password",
|
||||
"confirmPassword": "Confirm new password",
|
||||
"mismatch": "The two new passwords differ",
|
||||
"mustChangeSubmit": "Save and continue",
|
||||
"saving": "Saving…"
|
||||
},
|
||||
"insights": {
|
||||
"help": {
|
||||
|
||||
@@ -531,6 +531,9 @@
|
||||
"changes.unversionedGroup": "Unversioned Files",
|
||||
"changes.commitTicked": "Commit ({{count}})",
|
||||
"changes.commitAndPush": "Commit and Push",
|
||||
"changes.discardAll": "Discard all",
|
||||
"changes.discardAllTitle": "Discard all changes?",
|
||||
"changes.discardAllConfirm": "Every change in {{count}} file(s) goes back to the last commit, and {{untracked}} new file(s) git does not track yet are deleted. This cannot be undone.",
|
||||
"changes.amendAndPush": "Amend and Push",
|
||||
"changes.pushFailed": "Committed, but the push failed: {{error}}",
|
||||
"changes.tickFile": "Include {{path}} in the commit",
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"findClose": "Close find"
|
||||
},
|
||||
"chat": {
|
||||
"codexPicker": "Opens Codex's own picker — answer it in the chat",
|
||||
"placeholder": "Write a message…",
|
||||
"send": "Send",
|
||||
"notSent": "The terminal is not connected — the message was not sent.",
|
||||
@@ -139,7 +140,28 @@
|
||||
},
|
||||
"menu": {
|
||||
"placeholder": "Type your answer…",
|
||||
"cancel": "Cancel (Esc)"
|
||||
"cancel": "Cancel (Esc)",
|
||||
"prev": "Previous question",
|
||||
"next": "Next question"
|
||||
},
|
||||
"request": {
|
||||
"notePlaceholder": "Tell the model what to do instead…",
|
||||
"send": "Send",
|
||||
"denyWithNote": "Deny with a note"
|
||||
},
|
||||
"artifact": {
|
||||
"preview": "Preview",
|
||||
"code": "Code",
|
||||
"copy": "Copy",
|
||||
"download": "Download",
|
||||
"share": "Save to Artifacts",
|
||||
"shared": "Saved to your Artifacts library — share it from the Artifacts page.",
|
||||
"cannotShare": "This file cannot be saved to Artifacts.",
|
||||
"expand": "Full screen",
|
||||
"collapse": "Exit full screen",
|
||||
"older": "Older version",
|
||||
"newer": "Newer version",
|
||||
"version": "Version {{n}} of {{total}}"
|
||||
}
|
||||
},
|
||||
"interrupted": {
|
||||
|
||||
@@ -230,6 +230,15 @@
|
||||
"stopped": "stopped"
|
||||
},
|
||||
"hide": "Hide conversation"
|
||||
},
|
||||
"artifact": {
|
||||
"html": "Web page",
|
||||
"svg": "Image · SVG",
|
||||
"markdown": "Document",
|
||||
"mermaid": "Diagram",
|
||||
"react": "React component",
|
||||
"csv": "Table",
|
||||
"open": "Click to open"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
|
||||
@@ -1140,6 +1140,21 @@
|
||||
"vi": "Tiếng Việt"
|
||||
}
|
||||
},
|
||||
"ompSharing": {
|
||||
"title": "Oh My Pi login sharing",
|
||||
"switch": "Share this server's Oh My Pi login",
|
||||
"hint": "Every user's omp sessions run on the logins the host account made with `omp login`, through omp's own auth broker and gateway on this machine. A session gets only a key that works while its pane runs, never the login itself. Only omp's built-in providers are shared.",
|
||||
"running": "Running: broker and gateway are up.",
|
||||
"starting": "Starting…",
|
||||
"notInstalled": "omp is not installed on this server.",
|
||||
"failed": "Not running: {{error}}"
|
||||
},
|
||||
"markdownView": {
|
||||
"label": "Open Markdown as",
|
||||
"description": "How a .md file opens in Files, Changes, the config viewer and a chat's file panel. Its Preview/Raw button still switches that one file. Saved to your account.",
|
||||
"preview": "Preview",
|
||||
"raw": "Raw"
|
||||
},
|
||||
"workspaceTabs": {
|
||||
"title": "Workspace tabs",
|
||||
"description": "Which sessions the session view's tab bar holds. Saved to your account.",
|
||||
@@ -1302,9 +1317,9 @@
|
||||
},
|
||||
"chatUi": {
|
||||
"title": "Chat UI",
|
||||
"description": "How a new Claude or Codex session opens in its workspace. Saved for your account only.",
|
||||
"description": "How a new Claude, Codex, omp, Cursor or Antigravity session opens in its workspace. Saved for your account only.",
|
||||
"label": "Open new sessions as a chat",
|
||||
"labelDesc": "A Claude or Codex session first shows its conversation like Claude Desktop, with a message box, instead of its terminal. Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and menus still need the terminal.",
|
||||
"labelDesc": "A new session first shows its conversation like Claude Desktop, with a message box, instead of its terminal (the terminal stays underneath). Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and questions are answered from cards in the chat.",
|
||||
"saveFailed": "Could not save: {{message}}"
|
||||
},
|
||||
"sessionDefaults": {
|
||||
@@ -1339,7 +1354,10 @@
|
||||
"remove": "Remove picture",
|
||||
"saved": "Picture updated",
|
||||
"removed": "Picture removed",
|
||||
"tooBig": "The picture must be at most 1 MB"
|
||||
"tooBig": "The picture must be at most 1 MB",
|
||||
"fromUrl": "Use a picture from a link",
|
||||
"urlPlaceholder": "https://… image link",
|
||||
"urlApply": "Use"
|
||||
},
|
||||
"loadFailed": "Could not load your account: {{message}}",
|
||||
"memberSince": "Member since",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"username": "Username",
|
||||
"usernameHint": "Letters, digits, '.', '_' or '-'",
|
||||
"password": "Password",
|
||||
"passwordHint": "No minimum length",
|
||||
"passwordHint": "Any starting password — they choose their own at first sign-in",
|
||||
"newPassword": "New password",
|
||||
"changeRole": "Change role",
|
||||
"disable": "Disable",
|
||||
@@ -68,6 +68,9 @@
|
||||
"errors": {
|
||||
"EBADIMAGE": "Only a PNG, JPEG, GIF or WebP picture",
|
||||
"ETOOBIG": "The picture must be at most 1 MB",
|
||||
"EBADURL": "Enter an https:// link to a picture.",
|
||||
"EPRIVATEURL": "That link points to a private or local address.",
|
||||
"EFETCH": "Could not download the picture from that link.",
|
||||
"EREAUTH": "Your password is wrong.",
|
||||
"ETOOMANY": "Too many wrong passwords. Try again in 15 minutes.",
|
||||
"ESELF": "You cannot change your own role or disable yourself.",
|
||||
@@ -81,7 +84,9 @@
|
||||
"EOWNERFOLDER": "That workspace's folder belongs to another user's account.",
|
||||
"EBADROLE": "Unknown role.",
|
||||
"ELOCALMODE": "Accounts are managed in server mode only.",
|
||||
"ELASTADMIN": "The last admin cannot be deleted."
|
||||
"ELASTADMIN": "The last admin cannot be deleted, demoted or disabled.",
|
||||
"EWEAKPASSWORD": "The new password does not meet every rule (or is the current one).",
|
||||
"EPWCHANGE": "Choose your own password first."
|
||||
},
|
||||
"readOnly": "Analysts can view accounts but not change them.",
|
||||
"auditTitle": "Audit log",
|
||||
@@ -107,7 +112,7 @@
|
||||
"selfRole": "You cannot change your own role.",
|
||||
"selfDisable": "You cannot disable yourself.",
|
||||
"password": "Password",
|
||||
"passwordHint": "Leave blank to keep the current one. A new one signs them out everywhere.",
|
||||
"passwordHint": "Leave blank to keep the current one. A new one signs them out everywhere, and they choose their own at their next sign-in.",
|
||||
"show": "Show password",
|
||||
"hide": "Hide password",
|
||||
"copy": "Copy password",
|
||||
@@ -122,7 +127,8 @@
|
||||
"sessions": "Sign-ins",
|
||||
"signOut": "Sign out everywhere",
|
||||
"signOutHint": "Ends this user's {{n}} active sign-in(s); they sign in again with their password.",
|
||||
"noChanges": "No changes yet."
|
||||
"noChanges": "No changes yet.",
|
||||
"ownPasswordHint": "Leave blank to keep the current one. Your other sign-ins end."
|
||||
},
|
||||
"pool": {
|
||||
"title": "Account pool",
|
||||
@@ -253,5 +259,15 @@
|
||||
"openHome": "Open home in Files",
|
||||
"copyUsername": "Copy username",
|
||||
"copyEmail": "Copy email"
|
||||
},
|
||||
"mustChange": "New password due",
|
||||
"mustChangeHint": "Signed in with a password an admin gave; must choose their own before using AgentHub",
|
||||
"passwordRules": {
|
||||
"length": "At least {{min}} characters",
|
||||
"lower": "A lower-case letter",
|
||||
"upper": "An upper-case letter",
|
||||
"digit": "A digit",
|
||||
"symbol": "A symbol (e.g. ! - _ @)",
|
||||
"username": "Does not contain the username"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,8 @@
|
||||
"form.save": "Thêm",
|
||||
"form.cancel": "Huỷ",
|
||||
"added": "Đã thêm",
|
||||
"files": "{{count}} file",
|
||||
"files_one": "{{count}} file",
|
||||
"files_other": "{{count}} file",
|
||||
"close": "Đóng",
|
||||
"fromSession": "Từ một phiên",
|
||||
"form.saveEdit": "Lưu",
|
||||
@@ -116,6 +117,20 @@
|
||||
"edit.description": "Mô tả",
|
||||
"previewFailed": "Không tải được file này.",
|
||||
"truncated": "Chỉ hiển thị 1 MB đầu — tải file về để xem toàn bộ.",
|
||||
"section.hub.own": "Tôi đã share",
|
||||
"section.hub.ownHint": "Của bạn, mọi người dùng đều thấy",
|
||||
"section.hub.others": "Người khác share",
|
||||
"section.hub.othersHint": "Những gì người dùng khác đã share — cài cái bạn cần",
|
||||
"section.mine.own": "Của tôi",
|
||||
"section.mine.ownHint": "Những gì bạn đã thêm",
|
||||
"section.mine.others": "Đã cài từ người khác",
|
||||
"section.mine.othersHint": "Người dùng khác share, đã cài vào tài khoản của bạn",
|
||||
"section.all.others": "Người khác share",
|
||||
"section.all.othersHint": "Những gì người dùng khác đã share",
|
||||
"section.all.own": "Của tôi",
|
||||
"section.all.ownHint": "Những gì bạn đã thêm, đã share hoặc chưa",
|
||||
"pages": "Trang",
|
||||
"section.empty": "Chưa có gì.",
|
||||
"tab.all": "Tất cả",
|
||||
"installedBadge": "Đã cài",
|
||||
"installsTitle": "Lượt cài",
|
||||
@@ -125,10 +140,11 @@
|
||||
"installsPluginWhere": "claude plugin install --scope user",
|
||||
"installsUnrecorded": "{{count}} lượt cài trước đây chỉ được đếm, không lưu chi tiết",
|
||||
"installsNoneOfYours": "Bạn chưa cài — chỉ hiện các lượt cài của bạn.",
|
||||
"reinstall": "Cài lại",
|
||||
"reinstallHint": "Ghi bản này đè lên bản bạn đã cài",
|
||||
"reinstallTitle": "Cài lại “{{title}}”?",
|
||||
"reinstallBody": "Bản đã cài ở ~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ sẽ được thay bằng bản này. Bản hiện tại được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá.",
|
||||
"uninstall": "Gỡ cài đặt",
|
||||
"uninstallTitle": "Gỡ cài đặt “{{title}}”?",
|
||||
"uninstallSkillBody": "~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ sẽ được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá. Các phiên đang mở vẫn giữ nó cho tới khi tải lại.",
|
||||
"uninstallPluginBody": "Chạy claude plugin uninstall {{name}} cho tài khoản của bạn; thư mục dữ liệu của plugin được giữ lại. Các phiên đang mở vẫn giữ nó cho tới khi /reload-plugins.",
|
||||
"uninstalled": "Đã gỡ {{name}}",
|
||||
"deleteBodyShared": "Mục này đang được chia sẻ nên cũng sẽ biến mất khỏi Hub với mọi người. Muốn giữ lại nhưng thôi chia sẻ thì dùng Bỏ chia sẻ. Những gì đã cài từ nó vẫn được giữ.",
|
||||
"deleteBodyKeep": "Mục này rời khỏi thư viện của bạn nhưng vẫn ở trên Hub vì đang được chia sẻ. Muốn gỡ khỏi Hub luôn thì sau đó bấm Gỡ khỏi Hub (hoặc Bỏ chia sẻ trước).",
|
||||
"deleteKept": "Đã xoá khỏi thư viện — vẫn còn trên Hub",
|
||||
@@ -138,10 +154,34 @@
|
||||
"hubRemoveTitle": "Gỡ “{{title}}” khỏi Hub?",
|
||||
"hubRemoveBody": "Mục này không còn trong thư viện của bạn nên sẽ bị xoá hẳn. Những gì đã cài từ nó vẫn được giữ.",
|
||||
"edit.otherFiles": "{{count}} file khác của skill được giữ nguyên.",
|
||||
"edit.reinstallNote": "Bản bạn đã cài vẫn là bản cũ cho đến khi bấm Cài lại.",
|
||||
"edit.reinstallNote": "Bản bạn đã cài vẫn là bản cũ cho đến khi bạn gỡ cài đặt rồi cài lại.",
|
||||
"preview.instructions": "Hướng dẫn",
|
||||
"preview.frontmatter": "Frontmatter (phần đầu SKILL.md)",
|
||||
"preview.whenUsed": "Khi nào Claude dùng",
|
||||
"preview.noDescription": "Chưa có mô tả",
|
||||
"preview.installsAs": "Cài vào ~/.claude/skills/{{slug}}/ và ~/.agents/skills/{{slug}}/"
|
||||
"preview.installsAs": "Cài vào ~/.claude/skills/{{slug}}/ và ~/.agents/skills/{{slug}}/",
|
||||
"blurb.skill": "Bộ hướng dẫn tái sử dụng cho Claude",
|
||||
"blurb.plugin": "Gói skill, lệnh và hook",
|
||||
"blurb.page": "Trang mà phiên đã publish lên claude.ai",
|
||||
"blurb.file": "Tài liệu và kết quả đáng giữ lại",
|
||||
"clearSearch": "Xoá tìm kiếm",
|
||||
"categories": "Danh mục",
|
||||
"seeAll": "Xem tất cả",
|
||||
"results_one": "{{count}} kết quả",
|
||||
"results_other": "{{count}} kết quả",
|
||||
"empty.title": "Thư viện đang trống",
|
||||
"empty.hubTitle": "Hub chưa có gì",
|
||||
"empty.searchTitle": "Không tìm thấy",
|
||||
"empty.search": "Không có mục nào khớp tìm kiếm hoặc danh mục này — thử từ khác, hoặc chọn Tất cả.",
|
||||
"details": "Chi tiết",
|
||||
"more": "Thao tác khác",
|
||||
"about": "Giới thiệu",
|
||||
"stat.kind": "Loại",
|
||||
"stat.installs": "Lượt cài",
|
||||
"stat.files": "File",
|
||||
"stat.size": "Dung lượng",
|
||||
"stat.updated": "Cập nhật",
|
||||
"detail.runAs": "Gọi bằng",
|
||||
"detail.market": "Marketplace",
|
||||
"detail.added": "Thêm lúc {{date}}"
|
||||
}
|
||||
|
||||
@@ -198,7 +198,15 @@
|
||||
"tooMany": "Sai quá nhiều lần — hãy thử lại sau vài phút",
|
||||
"logout": "Đăng xuất",
|
||||
"noAccount": "Chưa có tài khoản? Hãy nhờ quản trị viên tạo cho bạn.",
|
||||
"signedInAs": "Đang đăng nhập: {{name}} ({{role}})"
|
||||
"signedInAs": "Đang đăng nhập: {{name}} ({{role}})",
|
||||
"mustChangeTitle": "Đặt mật khẩu của bạn",
|
||||
"mustChangeSubtitle": "Quản trị viên đã cấp cho bạn mật khẩu ban đầu. Hãy đặt mật khẩu riêng trước khi tiếp tục.",
|
||||
"currentPassword": "Mật khẩu hiện tại",
|
||||
"newPassword": "Mật khẩu mới",
|
||||
"confirmPassword": "Nhập lại mật khẩu mới",
|
||||
"mismatch": "Hai mật khẩu mới không khớp",
|
||||
"mustChangeSubmit": "Lưu và tiếp tục",
|
||||
"saving": "Đang lưu…"
|
||||
},
|
||||
"insights": {
|
||||
"help": {
|
||||
|
||||
@@ -531,6 +531,9 @@
|
||||
"changes.unversionedGroup": "File chưa theo dõi",
|
||||
"changes.commitTicked": "Commit ({{count}})",
|
||||
"changes.commitAndPush": "Commit và Push",
|
||||
"changes.discardAll": "Bỏ tất cả",
|
||||
"changes.discardAllTitle": "Bỏ tất cả thay đổi?",
|
||||
"changes.discardAllConfirm": "Mọi thay đổi trong {{count}} file sẽ trở về commit cuối, và {{untracked}} file mới chưa được git theo dõi sẽ bị xoá. Không thể hoàn tác.",
|
||||
"changes.amendAndPush": "Amend và Push",
|
||||
"changes.pushFailed": "Đã commit, nhưng push thất bại: {{error}}",
|
||||
"changes.tickFile": "Đưa {{path}} vào commit",
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"findClose": "Đóng tìm kiếm"
|
||||
},
|
||||
"chat": {
|
||||
"codexPicker": "Mở bộ chọn của chính Codex — trả lời ngay trong chat",
|
||||
"placeholder": "Viết tin nhắn…",
|
||||
"send": "Gửi",
|
||||
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi.",
|
||||
@@ -139,7 +140,28 @@
|
||||
},
|
||||
"menu": {
|
||||
"placeholder": "Nhập câu trả lời của bạn…",
|
||||
"cancel": "Huỷ (Esc)"
|
||||
"cancel": "Huỷ (Esc)",
|
||||
"prev": "Câu trước",
|
||||
"next": "Câu tiếp"
|
||||
},
|
||||
"request": {
|
||||
"notePlaceholder": "Nói cho model biết nên làm gì thay vào đó…",
|
||||
"send": "Gửi",
|
||||
"denyWithNote": "Từ chối kèm lời nhắn"
|
||||
},
|
||||
"artifact": {
|
||||
"preview": "Xem trước",
|
||||
"code": "Mã",
|
||||
"copy": "Sao chép",
|
||||
"download": "Tải xuống",
|
||||
"share": "Lưu vào Artifacts",
|
||||
"shared": "Đã lưu vào thư viện Artifacts của bạn — chia sẻ từ trang Artifacts.",
|
||||
"cannotShare": "Không lưu được file này vào Artifacts.",
|
||||
"expand": "Toàn màn hình",
|
||||
"collapse": "Thoát toàn màn hình",
|
||||
"older": "Phiên bản cũ hơn",
|
||||
"newer": "Phiên bản mới hơn",
|
||||
"version": "Phiên bản {{n}}/{{total}}"
|
||||
}
|
||||
},
|
||||
"interrupted": {
|
||||
|
||||
@@ -230,6 +230,15 @@
|
||||
"stopped": "đã dừng"
|
||||
},
|
||||
"hide": "Ẩn hội thoại"
|
||||
},
|
||||
"artifact": {
|
||||
"html": "Trang web",
|
||||
"svg": "Ảnh · SVG",
|
||||
"markdown": "Tài liệu",
|
||||
"mermaid": "Sơ đồ",
|
||||
"react": "Component React",
|
||||
"csv": "Bảng",
|
||||
"open": "Bấm để mở"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
|
||||
@@ -1140,6 +1140,21 @@
|
||||
"vi": "Tiếng Việt"
|
||||
}
|
||||
},
|
||||
"ompSharing": {
|
||||
"title": "Dùng chung đăng nhập Oh My Pi",
|
||||
"switch": "Dùng chung đăng nhập Oh My Pi của máy chủ",
|
||||
"hint": "Mọi session omp của người dùng chạy bằng các đăng nhập mà tài khoản máy chủ đã tạo bằng `omp login`, qua auth broker và gateway của chính omp trên máy này. Session chỉ nhận một key dùng được khi pane còn chạy, không bao giờ nhận chính đăng nhập. Chỉ các provider có sẵn của omp được dùng chung.",
|
||||
"running": "Đang chạy: broker và gateway đã sẵn sàng.",
|
||||
"starting": "Đang khởi động…",
|
||||
"notInstalled": "Máy chủ chưa cài omp.",
|
||||
"failed": "Không chạy: {{error}}"
|
||||
},
|
||||
"markdownView": {
|
||||
"label": "Mở file Markdown dạng",
|
||||
"description": "Cách file .md mở trong Files, Changes, trình xem cấu hình và bảng file của chat. Nút Xem trước/Mã nguồn vẫn đổi riêng file đó. Lưu theo tài khoản.",
|
||||
"preview": "Xem trước",
|
||||
"raw": "Mã nguồn"
|
||||
},
|
||||
"workspaceTabs": {
|
||||
"title": "Tab của workspace",
|
||||
"description": "Thanh tab của màn hình session chứa những session nào. Lưu theo tài khoản.",
|
||||
@@ -1302,9 +1317,9 @@
|
||||
},
|
||||
"chatUi": {
|
||||
"title": "Giao diện chat",
|
||||
"description": "Cách một session Claude hoặc Codex mới mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
|
||||
"description": "Cách một session Claude, Codex, omp, Cursor hoặc Antigravity mới mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
|
||||
"label": "Mở session mới dạng chat",
|
||||
"labelDesc": "Session Claude hoặc Codex sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal. Sau đó mỗi session giữ dạng xem riêng: nút chat/terminal cạnh nút split của pane để chuyển. Hỏi quyền và menu vẫn cần terminal.",
|
||||
"labelDesc": "Session mới sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal (terminal vẫn chạy bên dưới). Sau đó mỗi session giữ dạng xem riêng: nút chat/terminal cạnh nút split của pane để chuyển. Hỏi quyền và câu hỏi được trả lời bằng thẻ ngay trong chat.",
|
||||
"saveFailed": "Không lưu được: {{message}}"
|
||||
},
|
||||
"sessionDefaults": {
|
||||
@@ -1339,7 +1354,10 @@
|
||||
"remove": "Xoá ảnh đại diện",
|
||||
"saved": "Đã cập nhật ảnh đại diện",
|
||||
"removed": "Đã xoá ảnh đại diện",
|
||||
"tooBig": "Ảnh tối đa 1 MB"
|
||||
"tooBig": "Ảnh tối đa 1 MB",
|
||||
"fromUrl": "Dùng ảnh từ đường dẫn",
|
||||
"urlPlaceholder": "https://… đường dẫn ảnh",
|
||||
"urlApply": "Dùng"
|
||||
},
|
||||
"loadFailed": "Không tải được tài khoản: {{message}}",
|
||||
"memberSince": "Tham gia từ",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"username": "Tên đăng nhập",
|
||||
"usernameHint": "Chữ, số, '.', '_' hoặc '-'",
|
||||
"password": "Mật khẩu",
|
||||
"passwordHint": "Không giới hạn độ dài tối thiểu",
|
||||
"passwordHint": "Mật khẩu ban đầu tuỳ ý — người dùng sẽ tự đặt mật khẩu riêng ở lần đăng nhập đầu",
|
||||
"newPassword": "Mật khẩu mới",
|
||||
"changeRole": "Đổi vai trò",
|
||||
"disable": "Khoá",
|
||||
@@ -68,6 +68,9 @@
|
||||
"errors": {
|
||||
"EBADIMAGE": "Chỉ nhận ảnh PNG, JPEG, GIF hoặc WebP",
|
||||
"ETOOBIG": "Ảnh tối đa 1 MB",
|
||||
"EBADURL": "Hãy nhập đường dẫn https:// tới một ảnh.",
|
||||
"EPRIVATEURL": "Đường dẫn trỏ tới địa chỉ nội bộ hoặc cục bộ.",
|
||||
"EFETCH": "Không tải được ảnh từ đường dẫn này.",
|
||||
"EREAUTH": "Mật khẩu của bạn không đúng.",
|
||||
"ETOOMANY": "Nhập sai mật khẩu quá nhiều lần. Thử lại sau 15 phút.",
|
||||
"ESELF": "Bạn không thể tự đổi vai trò hay tự khoá chính mình.",
|
||||
@@ -81,7 +84,9 @@
|
||||
"EOWNERFOLDER": "Thư mục của workspace đó thuộc tài khoản của người dùng khác.",
|
||||
"EBADROLE": "Vai trò không hợp lệ.",
|
||||
"ELOCALMODE": "Chỉ quản lý tài khoản được ở chế độ server.",
|
||||
"ELASTADMIN": "Không thể xoá admin cuối cùng."
|
||||
"ELASTADMIN": "Không thể xoá, hạ quyền hay khoá admin cuối cùng.",
|
||||
"EWEAKPASSWORD": "Mật khẩu mới chưa đạt đủ các yêu cầu (hoặc trùng mật khẩu hiện tại).",
|
||||
"EPWCHANGE": "Hãy đặt mật khẩu riêng của bạn trước."
|
||||
},
|
||||
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
||||
"auditTitle": "Nhật ký audit",
|
||||
@@ -107,7 +112,7 @@
|
||||
"selfRole": "Bạn không thể đổi vai trò của chính mình.",
|
||||
"selfDisable": "Bạn không thể tự vô hiệu hoá mình.",
|
||||
"password": "Mật khẩu",
|
||||
"passwordHint": "Để trống để giữ mật khẩu hiện tại. Mật khẩu mới sẽ đăng xuất họ ở mọi nơi.",
|
||||
"passwordHint": "Để trống để giữ mật khẩu hiện tại. Mật khẩu mới sẽ đăng xuất họ ở mọi nơi và họ phải tự đặt mật khẩu riêng ở lần đăng nhập sau.",
|
||||
"show": "Hiện mật khẩu",
|
||||
"hide": "Ẩn mật khẩu",
|
||||
"copy": "Sao chép mật khẩu",
|
||||
@@ -122,7 +127,8 @@
|
||||
"sessions": "Phiên đăng nhập",
|
||||
"signOut": "Đăng xuất ở mọi nơi",
|
||||
"signOutHint": "Kết thúc {{n}} phiên đăng nhập đang hoạt động của người này; họ đăng nhập lại bằng mật khẩu.",
|
||||
"noChanges": "Chưa có thay đổi."
|
||||
"noChanges": "Chưa có thay đổi.",
|
||||
"ownPasswordHint": "Để trống để giữ mật khẩu hiện tại. Các phiên đăng nhập khác của bạn sẽ kết thúc."
|
||||
},
|
||||
"pool": {
|
||||
"title": "Pool tài khoản",
|
||||
@@ -253,5 +259,15 @@
|
||||
"openHome": "Mở home trong Files",
|
||||
"copyUsername": "Sao chép username",
|
||||
"copyEmail": "Sao chép email"
|
||||
},
|
||||
"mustChange": "Chờ đổi mật khẩu",
|
||||
"mustChangeHint": "Đang dùng mật khẩu do admin cấp; phải tự đặt mật khẩu riêng trước khi dùng AgentHub",
|
||||
"passwordRules": {
|
||||
"length": "Ít nhất {{min}} ký tự",
|
||||
"lower": "Có chữ thường",
|
||||
"upper": "Có chữ hoa",
|
||||
"digit": "Có chữ số",
|
||||
"symbol": "Có ký tự đặc biệt (vd. ! - _ @)",
|
||||
"username": "Không chứa tên đăng nhập"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @file artifactView.test.ts
|
||||
* @description What counts as an artifact and how it is shown: kinds by
|
||||
* name, a React file only when written whole, a file's versions rebuilt from
|
||||
* the conversation's Write/Edit/MultiEdit inputs (a failed call skipped, an
|
||||
* edit to unseen text unknown), CSV rows, and the sandboxed documents.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
artifactDocument,
|
||||
artifactKind,
|
||||
artifactVersions,
|
||||
frameSandbox,
|
||||
isArtifact,
|
||||
parseCsv,
|
||||
} from "../artifactView";
|
||||
import type { TranscriptMessage } from "../types";
|
||||
|
||||
const use = (id: string, name: string, input: Record<string, unknown>): TranscriptMessage => ({
|
||||
type: "assistant",
|
||||
timestamp: `2026-10-21T00:00:0${id}.000Z`,
|
||||
content: [{ type: "tool_use", id, name, input } as TranscriptMessage["content"][number]],
|
||||
});
|
||||
const result = (id: string, isError = false): TranscriptMessage => ({
|
||||
type: "user",
|
||||
sender: "tool",
|
||||
timestamp: null,
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id,
|
||||
output: "",
|
||||
is_error: isError,
|
||||
} as TranscriptMessage["content"][number],
|
||||
],
|
||||
});
|
||||
|
||||
describe("artifactView", () => {
|
||||
it("knows an artifact by its name", () => {
|
||||
expect(artifactKind("/a/page.HTML")).toBe("html");
|
||||
expect(artifactKind("d.mmd")).toBe("mermaid");
|
||||
expect(artifactKind("App.tsx")).toBe("react");
|
||||
expect(artifactKind("x.go")).toBe("code");
|
||||
expect(isArtifact("report.md", false)).toBe(true);
|
||||
expect(isArtifact("App.tsx", false)).toBe(false);
|
||||
expect(isArtifact("App.tsx", true)).toBe(true);
|
||||
expect(isArtifact("server.js", true)).toBe(false);
|
||||
});
|
||||
|
||||
it("rebuilds a file's versions from Write, Edit and MultiEdit", () => {
|
||||
const msgs = [
|
||||
use("1", "Write", { file_path: "/w/a.md", content: "# A\nold line\n" }),
|
||||
result("1"),
|
||||
use("2", "Edit", { file_path: "/w/a.md", old_string: "old", new_string: "new" }),
|
||||
result("2"),
|
||||
use("3", "Edit", { file_path: "/w/a.md", old_string: "missing", new_string: "x" }),
|
||||
result("3", true),
|
||||
use("4", "MultiEdit", {
|
||||
file_path: "/w/a.md",
|
||||
edits: [{ old_string: "# A", new_string: "# B" }],
|
||||
}),
|
||||
result("4"),
|
||||
use("5", "Write", { file_path: "/w/other.md", content: "no" }),
|
||||
];
|
||||
const v = artifactVersions(msgs, "/w/a.md");
|
||||
expect(v.map((x) => x.content)).toEqual([
|
||||
"# A\nold line\n",
|
||||
"# A\nnew line\n",
|
||||
"# B\nnew line\n",
|
||||
]);
|
||||
});
|
||||
|
||||
it("says an edit to text it never saw is of unknown content", () => {
|
||||
const v = artifactVersions(
|
||||
[use("1", "Edit", { file_path: "a.md", old_string: "a", new_string: "b" })],
|
||||
"a.md"
|
||||
);
|
||||
expect(v).toHaveLength(1);
|
||||
expect(v[0]!.content).toBeNull();
|
||||
});
|
||||
|
||||
it("reads CSV with quotes and newlines inside them", () => {
|
||||
expect(parseCsv('a,b\n"x, y","say ""hi""\nthere"\n')).toEqual([
|
||||
["a", "b"],
|
||||
["x, y", 'say "hi"\nthere'],
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds sandboxed documents that keep the source inert inside a script", () => {
|
||||
const doc = artifactDocument("react", "export default () => <p>a</script>b</p>", false)!;
|
||||
expect(doc).toContain("babel");
|
||||
expect(doc).not.toContain("a</script>b");
|
||||
expect(artifactDocument("mermaid", "graph TD; A-->B<img>", true)).toContain(
|
||||
"A-->B<img>"
|
||||
);
|
||||
expect(artifactDocument("code", "x", false)).toBeNull();
|
||||
expect(frameSandbox("svg")).toBe("");
|
||||
expect(frameSandbox("html")).not.toContain("allow-same-origin");
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
chooseRunOption,
|
||||
parsePaneMenu,
|
||||
pasteToLane,
|
||||
readRunBusy,
|
||||
readRunMenu,
|
||||
registerPane,
|
||||
takeRunInput,
|
||||
@@ -160,6 +161,33 @@ describe("panePaste", () => {
|
||||
expect(m.typeAt).toBe(2);
|
||||
});
|
||||
|
||||
// Several questions: the unnumbered row is Next until the last one, and the
|
||||
// tab bar says which questions have an answer.
|
||||
it("reads a multi-question multiSelect with its Next row and question tabs", () => {
|
||||
const m = parsePaneMenu(MULTI_Q)!;
|
||||
expect(m.options.map((o) => [o.n, o.label, o.checked])).toEqual([
|
||||
[1, "JWT HS256 + default secret", true],
|
||||
[2, "Product code issue 2 lần", true],
|
||||
[3, "Type something", false],
|
||||
[null, "Next", null],
|
||||
[4, "Chat about this", null],
|
||||
]);
|
||||
expect(m.options[2]!.hint).toBe("");
|
||||
expect(m.cursor).toBe(0);
|
||||
expect(m.tabs).toEqual([
|
||||
{ label: "Critical", done: true },
|
||||
{ label: "High 1/3", done: false },
|
||||
{ label: "High 2/3", done: false },
|
||||
{ label: "High 3/3", done: false },
|
||||
]);
|
||||
expect(m.tab).toBe(0);
|
||||
expect(MENU_CURSOR_ROW.test("❯ Next")).toBe(true);
|
||||
});
|
||||
|
||||
it("reads no tabs for a single question", () => {
|
||||
expect(parsePaneMenu(ASK)!.tabs).toBeUndefined();
|
||||
});
|
||||
|
||||
// The "Type something" row once something is typed in it: its label is the
|
||||
// text. It is still the row right above "Chat about this".
|
||||
it("finds Type something by its place once text is typed in it, and replaces that text", () => {
|
||||
@@ -188,8 +216,11 @@ describe("panePaste", () => {
|
||||
const c = parsePaneMenu(PERMISSION)!;
|
||||
expect(c.question).toBe("Do you want to proceed?");
|
||||
expect(c.options.map((o) => o.label)).toEqual(["Yes", "Yes, and always allow", "No"]);
|
||||
// The tool and its command, drawn over the dashed rule.
|
||||
expect(c.detail).toEqual(["Bash command", "touch probe-file.txt"]);
|
||||
const x = parsePaneMenu(CODEX_APPROVAL)!;
|
||||
expect(x.question).toBe("Would you like to run the following command?\n$ touch probe.txt");
|
||||
expect(x.detail).toBeUndefined();
|
||||
expect(x.options).toHaveLength(3);
|
||||
expect(x.cursor).toBe(0);
|
||||
});
|
||||
@@ -317,6 +348,23 @@ const MULTI = [
|
||||
"",
|
||||
"Enter to select · Tab/Arrow keys to navigate · Esc to cancel",
|
||||
];
|
||||
const MULTI_Q = [
|
||||
RULE,
|
||||
"← ☒ Critical □ High 1/3 □ High 2/3 □ High 3/3 ✔ Submit →",
|
||||
"",
|
||||
"Critical (đã verify trong code). Chọn issue để log BUG:",
|
||||
"",
|
||||
"❯ 1. [✔] JWT HS256 + default secret",
|
||||
" pkg/middleware/auth.go:156,184 — HMAC only",
|
||||
" 2. [✔] Product code issue 2 lần",
|
||||
" gift product_code_repository.go:57 — không FOR UPDATE",
|
||||
" 3. [ ] Type something",
|
||||
" Next",
|
||||
RULE,
|
||||
" 4. Chat about this",
|
||||
"",
|
||||
"Enter to select · Tab/Arrow keys to navigate · Esc to cancel",
|
||||
];
|
||||
const PERMISSION = [
|
||||
" Bash command",
|
||||
" touch probe-file.txt",
|
||||
@@ -378,3 +426,51 @@ describe("MENU_CURSOR_ROW", () => {
|
||||
expect(has(CODEX)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readRunBusy", () => {
|
||||
it("reads each tool's own busy line, and nothing for a tool it does not know", () => {
|
||||
let rows = ["✻ Thinking… (4s · ↓ 120 tokens · esc to interrupt)"];
|
||||
const off = registerPane("agenthub-lane-8", () => {}, () => true, undefined, undefined, () => rows);
|
||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
|
||||
// Claude's permission footer is not a turn at work.
|
||||
rows = ["Esc to cancel · Tab to amend"];
|
||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
|
||||
expect(readRunBusy("agenthub-lane-8", "antigravity")).toBe(false);
|
||||
rows = ["Thinking… (3s · esc to cancel)"];
|
||||
expect(readRunBusy("agenthub-lane-8", "antigravity")).toBe(true);
|
||||
expect(readRunBusy("agenthub-lane-8", "omp")).toBeNull();
|
||||
off();
|
||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex's pickers", () => {
|
||||
it("reads /permissions with each row's description column as its hint", () => {
|
||||
const menu = parsePaneMenu([
|
||||
" >_ OpenAI Codex (v0.163.0)",
|
||||
"",
|
||||
" Update Model Permissions",
|
||||
"",
|
||||
"› 1. Ask for approval (current) Read and edit workspace files and run commands, with approval req",
|
||||
" access or edits outside the workspace",
|
||||
" 2. Approve for me Only ask for actions detected as potentially unsafe",
|
||||
" 3. Full Access Use with caution: Codex can edit files",
|
||||
" without approval",
|
||||
" 4. Read Only Read workspace files",
|
||||
"",
|
||||
" enter select · esc back",
|
||||
]);
|
||||
expect(menu?.question).toBe("Update Model Permissions");
|
||||
expect(menu?.cursor).toBe(0);
|
||||
expect(menu?.options.map((o) => [o.n, o.label, o.hint])).toEqual([
|
||||
[
|
||||
1,
|
||||
"Ask for approval (current)",
|
||||
"Read and edit workspace files and run commands, with approval req access or edits outside the workspace",
|
||||
],
|
||||
[2, "Approve for me", "Only ask for actions detected as potentially unsafe"],
|
||||
[3, "Full Access", "Use with caution: Codex can edit files without approval"],
|
||||
[4, "Read Only", "Read workspace files"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @file promptAttach.test.ts — `claudePastes`: how files reach a `claude` pane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { claudePastes } from "../promptAttach";
|
||||
|
||||
describe("claudePastes", () => {
|
||||
it("pastes each image as its bare path and the other files as one @ mention paste", () => {
|
||||
expect(
|
||||
claudePastes(["/d/drops/a.png", "/d/drops/b-report.pdf", "/d/x.JPG", "/d/c.log"])
|
||||
).toEqual(["/d/drops/a.png", "/d/x.JPG", "@/d/drops/b-report.pdf @/d/c.log"]);
|
||||
});
|
||||
|
||||
it("keeps a Windows or spaced image path a mention", () => {
|
||||
expect(claudePastes(["C:\\u\\drops\\a.png", "/home/u/my shot.png"])).toEqual([
|
||||
'@C:/u/drops/a.png @"/home/u/my shot.png"',
|
||||
]);
|
||||
});
|
||||
});
|
||||
+30
-1
@@ -733,6 +733,8 @@ export type AuthUser = {
|
||||
/** When the account's avatar was last set (null = none) — versions its URL. */
|
||||
avatarAt?: string | null;
|
||||
createdAt?: string;
|
||||
/** An admin gave this password: the user must choose their own before anything else. */
|
||||
mustChangePassword?: boolean;
|
||||
};
|
||||
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
|
||||
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
|
||||
@@ -1007,6 +1009,9 @@ export const api = {
|
||||
body: file,
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
}),
|
||||
/** POST /api/me/avatar/url — the server downloads the picture from an https link. */
|
||||
setAvatarFromUrl: (url: string) =>
|
||||
request<MyAccount>("/me/avatar/url", { method: "POST", body: JSON.stringify({ url }) }),
|
||||
/** DELETE /api/me/avatar — back to the initials. */
|
||||
removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }),
|
||||
},
|
||||
@@ -2210,7 +2215,8 @@ export const api = {
|
||||
* each one contributes (skills/agents/commands/hooks counts).
|
||||
* @returns {@link CcPluginsResponse} — the manifest path/status plus plugins.
|
||||
*/
|
||||
plugins: () => request<CcPluginsResponse>("/cc-config/plugins"),
|
||||
plugins: (cwd?: string) =>
|
||||
request<CcPluginsResponse>(`/cc-config/plugins${ccQuery(undefined, cwd)}`),
|
||||
/**
|
||||
* GET /api/cc-config/mcp - configured MCP servers, user and project-scoped.
|
||||
* @returns {@link CcMcpResponse} — servers split into `user`/`projectScoped`.
|
||||
@@ -3006,6 +3012,11 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ replace }),
|
||||
}),
|
||||
/** POST /api/artifacts/:id/uninstall — a skill moved aside, a plugin `claude plugin uninstall`. */
|
||||
uninstall: (id: number) =>
|
||||
request<{ uninstalled: string; paths: string[] }>(`/artifacts/${id}/uninstall`, {
|
||||
method: "POST",
|
||||
}),
|
||||
/** GET /api/artifacts/:id/installs — who installed it, when, and where. */
|
||||
installs: (id: number) => request<ArtifactInstalls>(`/artifacts/${id}/installs`),
|
||||
/** One of the snapshot's own files, served sandboxed. */
|
||||
@@ -3045,6 +3056,14 @@ export const api = {
|
||||
aiTools: {
|
||||
/** GET /api/ai-tools - the catalog, what is installed, enabled, and the default. */
|
||||
list: () => request<AiToolsView>("/ai-tools"),
|
||||
/** GET /api/ai-tools/omp-sharing - the host's Oh My Pi login shared with members (S3). */
|
||||
ompSharing: () => request<OmpSharingState>("/ai-tools/omp-sharing"),
|
||||
/** PUT /api/ai-tools/omp-sharing - an admin's switch. */
|
||||
setOmpSharing: (enabled: boolean) =>
|
||||
request<OmpSharingState>("/ai-tools/omp-sharing", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
/** POST /api/ai-tools/refresh - re-read the login shell's PATH and re-detect. */
|
||||
refresh: () => request<AiToolsView>("/ai-tools/refresh", { method: "POST" }),
|
||||
/** PUT /api/ai-tools/:id - enable/disable a tool, make it the default, or set its default model ("" clears). */
|
||||
@@ -4301,6 +4320,16 @@ export interface ArtifactCandidate {
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
/** `GET /api/ai-tools/omp-sharing` — never a token. */
|
||||
export interface OmpSharingState {
|
||||
/** Server mode with per-user Linux accounts (S3): the only place it applies. */
|
||||
available: boolean;
|
||||
enabled: boolean;
|
||||
installed: boolean;
|
||||
broker: { running: boolean; error?: string | null };
|
||||
gateway: { running: boolean; port?: number; error?: string | null };
|
||||
}
|
||||
|
||||
export interface ArtifactSettings {
|
||||
serverMode: boolean;
|
||||
canShare: boolean;
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* @file artifactView.ts
|
||||
* @description What makes a file an artifact, Claude Desktop's way, and how
|
||||
* one is shown: its kind from its name (a page, an image, a diagram, a React
|
||||
* component, a document, a table, code), the sandboxed document an HTML,
|
||||
* SVG, Mermaid or React artifact runs in (an iframe `srcdoc` with no
|
||||
* same-origin access, its libraries from a pinned CDN build, as Claude's own
|
||||
* artifacts load theirs), a CSV's rows, and the versions a conversation's
|
||||
* Write/Edit calls left of one file — rebuilt from their own inputs, so no
|
||||
* server keeps copies.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { TranscriptMessage } from "./types";
|
||||
|
||||
export type ArtifactKind =
|
||||
"html" | "svg" | "markdown" | "mermaid" | "react" | "csv" | "image" | "code";
|
||||
|
||||
const EXT = (p: string) =>
|
||||
(/\.([A-Za-z0-9]+)$/.exec(p.split(/[\\/]/).pop() || "")?.[1] || "").toLowerCase();
|
||||
|
||||
/** The kind a file shows as. */
|
||||
export function artifactKind(path: string): ArtifactKind {
|
||||
const e = EXT(path);
|
||||
if (e === "html" || e === "htm") return "html";
|
||||
if (e === "svg") return "svg";
|
||||
if (e === "md" || e === "markdown" || e === "mdx") return "markdown";
|
||||
if (e === "mmd" || e === "mermaid") return "mermaid";
|
||||
if (e === "jsx" || e === "tsx") return "react";
|
||||
if (e === "csv" || e === "tsv") return "csv";
|
||||
if (["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico"].includes(e)) return "image";
|
||||
return "code";
|
||||
}
|
||||
|
||||
/** Kinds with a rendered Preview (the rest show their code only). */
|
||||
export const RENDERED: ReadonlySet<ArtifactKind> = new Set([
|
||||
"html",
|
||||
"svg",
|
||||
"markdown",
|
||||
"mermaid",
|
||||
"react",
|
||||
"csv",
|
||||
"image",
|
||||
]);
|
||||
|
||||
/** An edit worth a card in the conversation: a page, a document, a diagram, a
|
||||
* table — and a React component the model wrote whole (a `.tsx` it merely
|
||||
* edited is project code, not an artifact). */
|
||||
export function isArtifact(path: string, created: boolean): boolean {
|
||||
const k = artifactKind(path);
|
||||
if (k === "react") return created;
|
||||
return k === "html" || k === "svg" || k === "markdown" || k === "mermaid" || k === "csv";
|
||||
}
|
||||
|
||||
/** A code fence language for the Code tab. */
|
||||
export function artifactLang(path: string): string {
|
||||
const e = EXT(path);
|
||||
const map: Record<string, string> = {
|
||||
htm: "html",
|
||||
mmd: "text",
|
||||
mermaid: "text",
|
||||
md: "markdown",
|
||||
mdx: "markdown",
|
||||
jsx: "jsx",
|
||||
tsx: "tsx",
|
||||
js: "javascript",
|
||||
mjs: "javascript",
|
||||
cjs: "javascript",
|
||||
ts: "typescript",
|
||||
py: "python",
|
||||
sh: "bash",
|
||||
yml: "yaml",
|
||||
};
|
||||
return map[e] || e;
|
||||
}
|
||||
|
||||
// Pinned builds (all on npm for well over two weeks), as Claude's own
|
||||
// artifacts load React, Tailwind and Mermaid from a CDN.
|
||||
const CDN = {
|
||||
react: "https://cdn.jsdelivr.net/npm/react@18.3.1/umd/react.production.min.js",
|
||||
reactDom: "https://cdn.jsdelivr.net/npm/react-dom@18.3.1/umd/react-dom.production.min.js",
|
||||
babel: "https://cdn.jsdelivr.net/npm/@babel/standalone@7.26.4/babel.min.js",
|
||||
tailwind: "https://cdn.tailwindcss.com/3.4.16",
|
||||
mermaid: "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs",
|
||||
};
|
||||
|
||||
/** Text safe inside a `<script>` element. */
|
||||
const scriptSafe = (s: string) => s.replace(/<\/(script)/gi, "<\\/$1");
|
||||
const htmlEscape = (s: string) =>
|
||||
s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
||||
|
||||
/** Shown inside the frame when the artifact itself fails. */
|
||||
const ERROR_BOX = `<pre id="__err" style="display:none;margin:16px;padding:12px;border-radius:8px;background:#fee2e2;color:#991b1b;font:12px/1.5 ui-monospace,monospace;white-space:pre-wrap"></pre>
|
||||
<script>window.addEventListener("error",function(e){var b=document.getElementById("__err");if(b){b.style.display="block";b.textContent=String(e.message||e.error||e);}});</script>`;
|
||||
|
||||
/** The document a rendered artifact runs in (null: it is not a frame kind). */
|
||||
export function artifactDocument(kind: ArtifactKind, source: string, dark: boolean): string | null {
|
||||
switch (kind) {
|
||||
case "html":
|
||||
return /<html[\s>]|<!doctype/i.test(source)
|
||||
? source
|
||||
: `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body>${source}</body></html>`;
|
||||
case "svg":
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%}body{display:flex;align-items:center;justify-content:center;background:${dark ? "#1f1f1f" : "#fff"}}svg{max-width:100%;max-height:100vh;height:auto}</style></head><body>${source}</body></html>`;
|
||||
case "mermaid":
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;padding:16px;background:${dark ? "#1f1f1f" : "#fff"};display:flex;justify-content:center}</style></head><body>${ERROR_BOX}<pre class="mermaid">${htmlEscape(source)}</pre>
|
||||
<script type="module">import mermaid from "${CDN.mermaid}";mermaid.initialize({startOnLoad:false,theme:${dark ? '"dark"' : '"default"'},securityLevel:"strict"});mermaid.run().catch(function(e){var b=document.getElementById("__err");b.style.display="block";b.textContent=String(e&&e.message||e);});</script></body></html>`;
|
||||
case "react":
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<script src="${CDN.react}"></script><script src="${CDN.reactDom}"></script><script src="${CDN.babel}"></script><script src="${CDN.tailwind}"></script></head>
|
||||
<body><div id="root"></div>${ERROR_BOX}
|
||||
<script>
|
||||
(function(){
|
||||
var src = ${JSON.stringify(scriptSafe(source))};
|
||||
var mods = { react: window.React, "react-dom": window.ReactDOM, "react-dom/client": window.ReactDOM };
|
||||
function req(name){ if (mods[name]) return mods[name]; throw new Error("This preview does not provide \\"" + name + "\\" — only react and react-dom are available."); }
|
||||
try {
|
||||
var out = Babel.transform(src, { filename: "Artifact.tsx", presets: [["typescript", { isTSX: true, allExtensions: true }], "react"], plugins: ["transform-modules-commonjs"] }).code;
|
||||
var module = { exports: {} };
|
||||
new Function("require", "module", "exports", "React", out)(req, module, module.exports, window.React);
|
||||
var C = module.exports.default || module.exports.App || Object.values(module.exports).find(function(v){ return typeof v === "function"; });
|
||||
if (!C) throw new Error("No component exported: export a default React component.");
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(React.createElement(C));
|
||||
} catch (e) { var b = document.getElementById("__err"); b.style.display = "block"; b.textContent = String(e && e.message || e); }
|
||||
})();
|
||||
</script></body></html>`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The sandbox a frame kind gets: scripts but never the dashboard's origin
|
||||
* (no `allow-same-origin`), and none at all for an SVG. */
|
||||
export const frameSandbox = (kind: ArtifactKind) =>
|
||||
kind === "svg" ? "" : "allow-scripts allow-forms allow-modals allow-popups allow-downloads";
|
||||
|
||||
/** A CSV/TSV's rows (quoted fields, doubled quotes, newlines inside quotes). */
|
||||
export function parseCsv(text: string, sep = ","): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = "";
|
||||
let quoted = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i]!;
|
||||
if (quoted) {
|
||||
if (c === '"' && text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i++;
|
||||
} else if (c === '"') quoted = false;
|
||||
else field += c;
|
||||
} else if (c === '"' && field === "") quoted = true;
|
||||
else if (c === sep) {
|
||||
row.push(field);
|
||||
field = "";
|
||||
} else if (c === "\n" || c === "\r") {
|
||||
if (c === "\r" && text[i + 1] === "\n") i++;
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
field = "";
|
||||
} else field += c;
|
||||
}
|
||||
if (field !== "" || row.length) {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** One version of a file, as a Write/Edit call left it. */
|
||||
export interface ArtifactVersion {
|
||||
/** The call that made it. */
|
||||
toolUseId: string | null;
|
||||
timestamp: string | null;
|
||||
/** null: the call is known, its result text is not (an edit to a file
|
||||
* whose earlier text the conversation never showed). */
|
||||
content: string | null;
|
||||
}
|
||||
|
||||
const samePath = (a: unknown, b: string) =>
|
||||
typeof a === "string" && a.replace(/\\/g, "/") === b.replace(/\\/g, "/");
|
||||
|
||||
/** Applies one Edit (old → new, once or everywhere) to known text. */
|
||||
function applyEdit(text: string, oldS: unknown, newS: unknown, all: unknown): string | null {
|
||||
if (typeof oldS !== "string" || typeof newS !== "string") return null;
|
||||
if (!text.includes(oldS)) return null;
|
||||
return all ? text.split(oldS).join(newS) : text.replace(oldS, () => newS);
|
||||
}
|
||||
|
||||
/**
|
||||
* The versions of `path` in a conversation, oldest first: each successful
|
||||
* Write gives its whole text, and each Edit/MultiEdit after a known text gives
|
||||
* that text with its edits applied. A call whose input the transcript cut, or
|
||||
* an edit to text never shown, is a version of unknown content (the current
|
||||
* file on disk stands in for the newest one).
|
||||
*/
|
||||
export function artifactVersions(messages: TranscriptMessage[], path: string): ArtifactVersion[] {
|
||||
const failed = new Set<string>();
|
||||
for (const m of messages)
|
||||
for (const c of m.content) if (c.type === "tool_result" && c.is_error && c.id) failed.add(c.id);
|
||||
const out: ArtifactVersion[] = [];
|
||||
let text: string | null = null;
|
||||
for (const m of messages) {
|
||||
if (m.type !== "assistant") continue;
|
||||
for (const c of m.content) {
|
||||
if (c.type !== "tool_use" || (c.id && failed.has(c.id))) continue;
|
||||
const input = (c.input || {}) as Record<string, unknown>;
|
||||
if (!samePath(input.file_path, path)) continue;
|
||||
if (c.name === "Write") {
|
||||
text = typeof input.content === "string" ? input.content : null;
|
||||
} else if (c.name === "Edit") {
|
||||
text =
|
||||
text === null
|
||||
? null
|
||||
: applyEdit(text, input.old_string, input.new_string, input.replace_all);
|
||||
} else if (c.name === "MultiEdit") {
|
||||
const edits = Array.isArray(input.edits) ? (input.edits as Record<string, unknown>[]) : [];
|
||||
for (const e of edits) {
|
||||
if (text === null) break;
|
||||
text = applyEdit(text, e.old_string, e.new_string, e.replace_all);
|
||||
}
|
||||
} else continue;
|
||||
out.push({ toolUseId: c.id ?? null, timestamp: m.timestamp, content: text });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @file chatUi.ts — whether a Claude/Codex session shows as a chat
|
||||
* (`PaneChat`, like Claude Desktop) or as its terminal. Each SESSION has its
|
||||
* @file chatUi.ts — which tools' terminal panes have a Chat view
|
||||
* (`CHAT_TOOLS`), and whether such a session shows as a chat (`PaneChat`,
|
||||
* like Claude Desktop) or as its terminal. Each SESSION has its
|
||||
* own view (`useSessionChat`, toggled by `setSessionChat` from the pane's
|
||||
* chat/terminal button), kept in this browser's localStorage
|
||||
* (`agenthub.chatUi.sessions`, the newest `MAX_VIEWS`). The account's Chat UI
|
||||
@@ -18,6 +19,19 @@ import { api } from "./api";
|
||||
|
||||
export const CHAT_UI_KEY = "agenthub.chatUi";
|
||||
|
||||
/** The tools whose terminal pane can show a chat: each one's pane learns its
|
||||
* session id (Claude's `--session-id`, the others' pollers through
|
||||
* `claimToolSessions`) and the server reads that session's conversation
|
||||
* (`GET /api/sessions/:id/transcript`). omp and Cursor send no hooks, so
|
||||
* their chat has no Stop button and follows at the pollers' pace. */
|
||||
export const CHAT_TOOLS: ReadonlySet<string> = new Set([
|
||||
"claude",
|
||||
"codex",
|
||||
"omp",
|
||||
"cursor",
|
||||
"antigravity",
|
||||
]);
|
||||
|
||||
export const VIEWS_KEY = "agenthub.chatUi.sessions";
|
||||
const MAX_VIEWS = 200;
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @file markdownView — Settings → Appearance → "Open Markdown as": a viewer
|
||||
* preference in `localStorage` (`agenthub.markdown.view`, synced to the
|
||||
* account through `lib/userPrefs.ts`). Preview (the default) opens a `.md`
|
||||
* file rendered; Raw opens its source. Read each time a viewer opens a file
|
||||
* (the Files/Changes panel, the config file viewer, a chat artifact); the
|
||||
* viewer's own Preview/Raw toggle still flips that one file.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const MARKDOWN_VIEW_KEY = "agenthub.markdown.view";
|
||||
|
||||
/** True when a Markdown file opens rendered (the default). */
|
||||
export function readMarkdownPreview(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(MARKDOWN_VIEW_KEY) !== "raw";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeMarkdownPreview(on: boolean) {
|
||||
try {
|
||||
if (on) window.localStorage.removeItem(MARKDOWN_VIEW_KEY);
|
||||
else window.localStorage.setItem(MARKDOWN_VIEW_KEY, "raw");
|
||||
} catch {
|
||||
/* storage blocked — the default */
|
||||
}
|
||||
}
|
||||
+115
-11
@@ -34,20 +34,24 @@ interface Pane {
|
||||
key?: (data: string) => void;
|
||||
/** `menuOnly`: null at once unless a row could be a menu's cursor row. */
|
||||
screen?: (menuOnly?: boolean) => PaneScreen | null;
|
||||
/** The rows on screen as plain text, dim cells kept. */
|
||||
lines?: () => string[];
|
||||
}
|
||||
const panes = new Map<string, Pane>();
|
||||
let lastFocused: string | null = null;
|
||||
|
||||
/** Registers a mounted terminal (`visible`: is it on screen now; `key`: send
|
||||
* raw input, as a key press; `screen`: its rows now); returns its unregister. */
|
||||
* raw input, as a key press; `screen`: its rows now; `lines`: the same as
|
||||
* plain text); returns its unregister. */
|
||||
export function registerPane(
|
||||
runId: string,
|
||||
paste: (text: string, submit?: boolean) => void,
|
||||
visible: () => boolean = () => true,
|
||||
key?: (data: string) => void,
|
||||
screen?: (menuOnly?: boolean) => PaneScreen | null
|
||||
screen?: (menuOnly?: boolean) => PaneScreen | null,
|
||||
lines?: () => string[]
|
||||
): () => void {
|
||||
const entry = { paste, visible, key, screen };
|
||||
const entry = { paste, visible, key, screen, lines };
|
||||
panes.set(runId, entry);
|
||||
return () => {
|
||||
if (panes.get(runId) === entry) panes.delete(runId);
|
||||
@@ -59,6 +63,26 @@ export function notePaneFocus(runId: string): void {
|
||||
lastFocused = runId;
|
||||
}
|
||||
|
||||
/** Who wants to hear that a pane drew something (its chat: the CLI is doing
|
||||
* something, so its transcript may have grown). */
|
||||
const outputListeners = new Map<string, Set<() => void>>();
|
||||
|
||||
/** A terminal received output for `runId` (TerminalView, every PTY frame). */
|
||||
export function notePaneOutput(runId: string): void {
|
||||
outputListeners.get(runId)?.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
/** Calls `fn` on every output `runId`'s terminal receives; returns its unregister. */
|
||||
export function onPaneOutput(runId: string, fn: () => void): () => void {
|
||||
let set = outputListeners.get(runId);
|
||||
if (!set) outputListeners.set(runId, (set = new Set()));
|
||||
set.add(fn);
|
||||
return () => {
|
||||
set!.delete(fn);
|
||||
if (!set!.size) outputListeners.delete(runId);
|
||||
};
|
||||
}
|
||||
|
||||
/** The tmux names of workspace `laneId`'s panes: `agenthub-lane-<id>[-slot]`. */
|
||||
function ofLane(runId: string, laneId: number): boolean {
|
||||
const base = `agenthub-lane-${laneId}`;
|
||||
@@ -162,32 +186,47 @@ export function takeRunInput(runId: string): string {
|
||||
}
|
||||
|
||||
/** One choice of a menu the CLI shows: `n` its number (null for Claude's
|
||||
* unnumbered Submit row), `checked` a multiSelect box's state. */
|
||||
* unnumbered Submit / Next row), `checked` a multiSelect box's state. */
|
||||
export type PaneMenuOption = {
|
||||
n: number | null;
|
||||
label: string;
|
||||
hint: string;
|
||||
checked: boolean | null;
|
||||
};
|
||||
/** One question tab of an AskUserQuestion that asks several: `done` once it
|
||||
* has an answer (`☒`). */
|
||||
export type PaneMenuTab = { label: string; done: boolean };
|
||||
/** A menu the CLI asks with (AskUserQuestion, a permission or trust prompt,
|
||||
* Codex's approval): `cursor` the row its `❯`/`›` is on, `typeAt` the
|
||||
* "Type something" row (-1: none). */
|
||||
* "Type something" row (-1: none). `tabs` only when it asks several
|
||||
* questions (`← ☒ A □ B ✔ Submit →`), `tab` the one shown (-1: unknown).
|
||||
* `detail` what a permission prompt asks about (its tool, then the command),
|
||||
* when the screen shows it apart from the question. */
|
||||
export type PaneMenu = {
|
||||
question: string;
|
||||
options: PaneMenuOption[];
|
||||
cursor: number;
|
||||
multi: boolean;
|
||||
typeAt: number;
|
||||
tabs?: PaneMenuTab[];
|
||||
tab?: number;
|
||||
detail?: string[];
|
||||
};
|
||||
|
||||
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/;
|
||||
/** 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$/;
|
||||
export const MENU_CURSOR_ROW =
|
||||
/^ *(?:[❯›][ \u00a0]* *(?:\d{1,2}\. |(?:Submit|Next)\s*$)|Enter to confirm)/;
|
||||
// The unnumbered button under a multiSelect's rows: Next while more questions
|
||||
// follow, Submit on the last one.
|
||||
const SUBMIT = /^ *([❯›])?[ \u00a0]*(Submit|Next)$/;
|
||||
const TAB_BAR = /^ *← +(.+?) +→ *$/;
|
||||
const RULE_ROW = /^ *[─╌]{3,} *$/;
|
||||
const DASHED_ROW = /^ *╌{3,} *$/;
|
||||
const CHECK = /^\[([ ✔])\] *(.*)$/;
|
||||
const PLACEHOLDER = /^Type something\.?$/;
|
||||
const COLUMN = /^(\S.*?\S) {2,}(\S.*)$/;
|
||||
|
||||
/** The numbered menu at the bottom of `rows`, or null. A menu has two
|
||||
* numbered rows or more from 1, the cursor on one of them, and no rule right
|
||||
@@ -207,10 +246,13 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
|
||||
const m = OPTION.exec(row);
|
||||
const s = m ? null : SUBMIT.exec(row);
|
||||
if (m || s) {
|
||||
const text = m ? (m[3] ?? "") : "Submit";
|
||||
const text = m ? (m[3] ?? "") : (s?.[2] ?? "Submit");
|
||||
const c = m ? CHECK.exec(text) : null;
|
||||
const label = c ? (c[2] ?? "") : text;
|
||||
const h = hint.join(" ");
|
||||
// Codex's pickers (`/model`, `/permissions`) draw each row's
|
||||
// description in a column after the name: split it off as the hint.
|
||||
const col = COLUMN.exec(c ? (c[2] ?? "") : text);
|
||||
const label = col ? col[1]! : c ? (c[2] ?? "") : text;
|
||||
const h = [col?.[2], ...hint].filter(Boolean).join(" ");
|
||||
options.unshift({
|
||||
n: m ? Number(m[2]) : null,
|
||||
label,
|
||||
@@ -239,15 +281,47 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
|
||||
const lines: string[] = [];
|
||||
for (; q >= 0 && r[q] && !RULE_ROW.test(r[q]!) && lines.length < 4; q--)
|
||||
lines.unshift(r[q]!.trim());
|
||||
const question = lines.join("\n");
|
||||
const tabs = tabBar(r.slice(Math.max(0, q - 4), Math.max(0, q + 1)));
|
||||
const low = question.toLowerCase();
|
||||
// What a permission prompt asks about: Claude draws the tool and its
|
||||
// command over a dashed rule above the question, up to the dialog's top rule.
|
||||
const detail: string[] = [];
|
||||
let d = q;
|
||||
while (d >= 0 && !r[d]) d--;
|
||||
if (d >= 0 && DASHED_ROW.test(r[d]!))
|
||||
for (d--; d >= 0 && !RULE_ROW.test(r[d]!) && detail.length < 12; d--)
|
||||
if (r[d]) detail.unshift(r[d]!.trim());
|
||||
return {
|
||||
question: lines.join("\n"),
|
||||
question,
|
||||
options,
|
||||
cursor: options.length - cursor,
|
||||
multi: options.some((o) => o.checked !== null),
|
||||
typeAt: typeRow(options),
|
||||
...(tabs.length > 1
|
||||
? { tabs, tab: tabs.findIndex((t) => low.startsWith(t.label.toLowerCase())) }
|
||||
: {}),
|
||||
...(detail.length ? { detail } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The question tabs of a multi-question AskUserQuestion, read off its bar
|
||||
* (`← ☒ Critical □ High 1/3 ✔ Submit →`) among `rows`; the Submit tab is
|
||||
* no question. [] when there is no bar. */
|
||||
function tabBar(rows: string[]): PaneMenuTab[] {
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
const m = TAB_BAR.exec(rows[i] ?? "");
|
||||
if (!m) continue;
|
||||
return (m[1] ?? "")
|
||||
.split(/(?=[☒☐□✔☑■] )/)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => ({ label: x.slice(1).trim(), done: /^[☒✔☑■]/.test(x) }))
|
||||
.filter((t) => t.label && t.label !== "Submit");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const BARE_CURSOR = /^( {0,4})❯[ \u00a0]+(\S.*)$/;
|
||||
|
||||
/** Claude's UNNUMBERED menu at the bottom of `r` (the folder-trust prompt:
|
||||
@@ -291,6 +365,26 @@ function typeRow(options: PaneMenuOption[]): number {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** The status line a tool's TUI shows only while a turn runs, read off its
|
||||
* binary's own strings: Claude's and Codex's `esc to interrupt`, agy's
|
||||
* `Thinking… (3s · esc to cancel)` — in parentheses after an ellipsis, so
|
||||
* Claude's permission footer (`Esc to cancel · Tab to amend`) is not it. A
|
||||
* tool missing here (omp, Cursor) has no line we know. */
|
||||
export const BUSY_LINE: Readonly<Record<string, RegExp>> = {
|
||||
claude: /\besc to interrupt\b/i,
|
||||
codex: /\besc to interrupt\b/i,
|
||||
antigravity: /…\s*\([^)]*\besc to cancel\)/i,
|
||||
};
|
||||
|
||||
/** Whether `runId`'s pane shows `tool` at work now (its BUSY_LINE), or null
|
||||
* when the tool's line is unknown or the pane is not mounted. */
|
||||
export function readRunBusy(runId: string, tool: string): boolean | null {
|
||||
const re = BUSY_LINE[tool];
|
||||
const p = panes.get(runId);
|
||||
if (!re || !p?.lines) return null;
|
||||
return p.lines().some((l) => re.test(l));
|
||||
}
|
||||
|
||||
/** The menu `runId`'s visible pane shows now, or null. */
|
||||
export function readRunMenu(runId: string): PaneMenu | null {
|
||||
const p = panes.get(runId);
|
||||
@@ -322,6 +416,16 @@ export function chooseRunOption(runId: string, menu: PaneMenu, index: number): b
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Moves a multi-question AskUserQuestion to its previous (-1) or next (1)
|
||||
* question tab, the arrows Claude's footer names. False when the pane is not
|
||||
* visible or not attached. */
|
||||
export function stepRunMenu(runId: string, dir: -1 | 1): boolean {
|
||||
const p = panes.get(runId);
|
||||
if (!p?.visible() || !p.key) return false;
|
||||
p.key(dir < 0 ? "\x1b[D" : "\x1b[C");
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Answers `menu` with typed text: the arrows to its "Type something" row,
|
||||
* Ctrl+U over what is typed there already (one key clears it, however long
|
||||
* it is), the text as a bracketed
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @file passwordRules.ts
|
||||
* @description The rules a password an account picks for ITSELF must pass —
|
||||
* a copy of `passwordProblems` in server/lib/auth.js, so the checklist can
|
||||
* tick each one as it is typed. The server's copy is the one that decides
|
||||
* (EWEAKPASSWORD); a password an admin gives someone else has no rules.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const PASSWORD_MIN_LENGTH = 8;
|
||||
|
||||
export const PASSWORD_RULES = ["length", "lower", "upper", "digit", "symbol", "username"] as const;
|
||||
export type PasswordRule = (typeof PASSWORD_RULES)[number];
|
||||
|
||||
/** The rules `password` fails (empty = it passes every one). */
|
||||
export function passwordProblems(password: string, username?: string): PasswordRule[] {
|
||||
const out: PasswordRule[] = [];
|
||||
if ([...password].length < PASSWORD_MIN_LENGTH) out.push("length");
|
||||
if (!/\p{Ll}/u.test(password)) out.push("lower");
|
||||
if (!/\p{Lu}/u.test(password)) out.push("upper");
|
||||
if (!/\p{N}/u.test(password)) out.push("digit");
|
||||
if (!/[^\p{L}\p{N}]/u.test(password)) out.push("symbol");
|
||||
const name = (username || "").toLowerCase();
|
||||
if (name && password.toLowerCase().includes(name)) out.push("username");
|
||||
return out;
|
||||
}
|
||||
@@ -169,6 +169,22 @@ export function claudeMention(path: string): string {
|
||||
return /\s/.test(p) ? `@"${p}"` : `@${p}`;
|
||||
}
|
||||
|
||||
const IMAGE_PATH = /\.(png|jpe?g|gif|webp)$/i;
|
||||
|
||||
/** What a terminal pastes for files dropped or pasted on a `claude` pane, one
|
||||
* paste per entry, as a local terminal hands them over: the images as their
|
||||
* bare paths, each a paste of its own — the shape Claude Code turns into
|
||||
* `[Image #n]`, as it does a native drop — and every other file as an `@`
|
||||
* mention, together in one. A Windows path, or
|
||||
* one with a space, stays a mention — the bare path stayed plain text there
|
||||
* (see `claudeMention`). */
|
||||
export function claudePastes(paths: string[]): string[] {
|
||||
const bare = (p: string) => IMAGE_PATH.test(p) && p.startsWith("/") && !/\s/.test(p);
|
||||
const images = paths.filter(bare);
|
||||
const others = paths.filter((p) => !bare(p)).map(claudeMention);
|
||||
return [...images, others.join(" ")].filter(Boolean);
|
||||
}
|
||||
|
||||
export const SKILL_MIME = "application/x-agenthub-skill";
|
||||
// A skill's name as Claude Code types it: `name` or `plugin:name`.
|
||||
const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/;
|
||||
|
||||
@@ -1930,7 +1930,10 @@ export interface WSMessage {
|
||||
/** A pane ended; refetch GET /api/run/interrupted (no payload). */
|
||||
| "runs_interrupted"
|
||||
/** An artifact was added, changed, shared or deleted; refetch the list. */
|
||||
| "artifact_changed";
|
||||
| "artifact_changed"
|
||||
/** A session's transcript grew on disk (`{session_id}`); a chat showing
|
||||
* it reads the new lines now. */
|
||||
| "transcript_changed";
|
||||
/** The message body, whose concrete shape is selected by `type` above. */
|
||||
data:
|
||||
| Session
|
||||
|
||||
@@ -43,6 +43,7 @@ export const PREF_KEYS = [
|
||||
"agenthub.codex.bypass",
|
||||
"agenthub.tour",
|
||||
"agenthub.workspace.allTabs",
|
||||
"agenthub.markdown.view",
|
||||
] as const;
|
||||
const SYNCED = new Set<string>(PREF_KEYS);
|
||||
const PUSH_DELAY_MS = 400;
|
||||
|
||||
+705
-247
File diff suppressed because it is too large
Load Diff
@@ -69,6 +69,7 @@ import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
|
||||
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
|
||||
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
||||
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
@@ -297,6 +298,8 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
<AiToolsSettings settingsHref={toolHref} />
|
||||
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
||||
<ArtifactsHubSettings />
|
||||
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
||||
<OmpSharingSettings />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
* account — moved to the Server tab, `MemberTmuxCard` in `ServerPanel`.)
|
||||
* Every change asks for the admin's OWN password again (the
|
||||
* server refuses without it); an analyst only reads. A row shows the display
|
||||
* name and email under the username when set; right-clicking a row offers
|
||||
* name and email under the username when set, and "New password due" while
|
||||
* the account still has a password an admin gave; right-clicking a row offers
|
||||
* Edit, Open home (in /files), and copying the username or email.
|
||||
* Laid out like the other list pages: icon header, a row of totals
|
||||
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
|
||||
@@ -273,6 +274,15 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
{t("disabled")}
|
||||
</span>
|
||||
)}
|
||||
{!u.disabled && u.mustChangePassword && (
|
||||
<span
|
||||
className="badge py-0.5 text-[11px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
||||
title={t("mustChangeHint")}
|
||||
data-testid="user-must-change"
|
||||
>
|
||||
{t("mustChange")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
* server cannot read opens the by-hand form), a skill is uploaded from one
|
||||
* Markdown file under the name the user types, or written in the dialog.
|
||||
* A card's right-click menu runs the same actions (Preview, Delete). A file
|
||||
* the sandbox cannot show (Office, zip, PDF) offers Download only, and the
|
||||
* Add menu closes on Escape or a click outside it.
|
||||
* the sandbox cannot show (Office, zip, PDF) offers Download only, the
|
||||
* Add menu closes on Escape or a click outside it, the front page is shelved
|
||||
* by kind until a category is picked, and a card opens its detail sheet.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -32,6 +33,7 @@ vi.mock("../../lib/api", () => ({
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
install: vi.fn(),
|
||||
uninstall: vi.fn(),
|
||||
installs: vi.fn(),
|
||||
setInstallsEnabled: vi.fn(),
|
||||
skills: vi.fn(),
|
||||
@@ -225,6 +227,66 @@ describe("Artifacts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("splits the Hub and My library into yours and others'", async () => {
|
||||
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
||||
serverMode: true,
|
||||
canShare: true,
|
||||
installsEnabled: true,
|
||||
canManage: false,
|
||||
});
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
item({ id: 1, title: "my-page", shared: true }),
|
||||
item({
|
||||
id: 2,
|
||||
kind: "skill",
|
||||
title: "their-skill",
|
||||
mine: false,
|
||||
owner: "an",
|
||||
shared: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
// All is split too, others' first.
|
||||
const allOthers = await screen.findByTestId("half-all.others");
|
||||
expect(within(allOthers).getByText("their-skill")).toBeInTheDocument();
|
||||
expect(
|
||||
allOthers.compareDocumentPosition(screen.getByTestId("half-all.own")) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy();
|
||||
await user.click(await screen.findByRole("button", { name: "Hub" }));
|
||||
const own = await screen.findByTestId("half-hub.own");
|
||||
expect(own).toHaveTextContent("Shared by me");
|
||||
expect(within(own).getByText("my-page")).toBeInTheDocument();
|
||||
const others = screen.getByTestId("half-hub.others");
|
||||
expect(within(others).getByText("their-skill")).toBeInTheDocument();
|
||||
expect(within(others).queryByText("my-page")).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "My library" }));
|
||||
expect(await screen.findByTestId("half-mine.others")).toHaveTextContent(
|
||||
"Installed from others"
|
||||
);
|
||||
expect(within(screen.getByTestId("half-mine.own")).getByText("my-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pages a long section 12 cards at a time", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: Array.from({ length: 14 }, (_, i) =>
|
||||
item({ id: i + 1, kind: "page", title: `p${i + 1}`, files: [] })
|
||||
),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("p1");
|
||||
expect(screen.queryByText("p13")).toBeNull();
|
||||
expect(screen.getByText("Showing 1-12 of 14")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||
expect(screen.getByText("p13")).toBeInTheDocument();
|
||||
expect(screen.queryByText("p1")).toBeNull();
|
||||
});
|
||||
|
||||
it("lists who installed an artifact, when and where, from its install count", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installs: 3 })],
|
||||
@@ -285,13 +347,43 @@ describe("Artifacts", () => {
|
||||
const card = await screen.findByTestId("artifact-2");
|
||||
expect(within(card).getByTestId("artifact-installed")).toHaveTextContent("Installed");
|
||||
expect(within(card).queryByRole("button", { name: "Install" })).toBeNull();
|
||||
// No Reinstall anywhere, and Uninstall lives only in the ⋯ menu.
|
||||
expect(within(card).queryByRole("button", { name: "Reinstall" })).toBeNull();
|
||||
expect(within(card).queryByRole("button", { name: "Uninstall" })).toBeNull();
|
||||
await userEvent.setup().click(within(card).getByRole("button", { name: "More actions" }));
|
||||
const menu = screen.getByTestId("artifact-menu");
|
||||
expect(within(menu).queryByRole("menuitem", { name: "Reinstall" })).toBeNull();
|
||||
expect(within(menu).getByRole("menuitem", { name: "Uninstall" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uninstalls an installed plugin only after a confirmation", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
item({ id: 4, kind: "plugin", title: "tool", source: "tool@market", installed: true }),
|
||||
],
|
||||
});
|
||||
vi.mocked(api.artifacts.uninstall).mockReset();
|
||||
open();
|
||||
const card = await screen.findByTestId("artifact-4");
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||
const uninstall = async () => {
|
||||
await user.click(within(card).getByRole("button", { name: "More actions" }));
|
||||
await user.click(
|
||||
within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name: "Uninstall" })
|
||||
);
|
||||
};
|
||||
await uninstall();
|
||||
await waitFor(() => expect(confirmDialog).toHaveBeenCalled());
|
||||
expect(api.artifacts.uninstall).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
vi.mocked(api.artifacts.install).mockResolvedValueOnce({
|
||||
installed: "/h/.claude/skills/deploy",
|
||||
vi.mocked(api.artifacts.uninstall).mockResolvedValueOnce({
|
||||
uninstalled: "tool@market",
|
||||
paths: [],
|
||||
});
|
||||
await userEvent.setup().click(within(card).getByRole("button", { name: "Reinstall" }));
|
||||
await waitFor(() => expect(api.artifacts.install).toHaveBeenLastCalledWith(2, true));
|
||||
await uninstall();
|
||||
await waitFor(() => expect(api.artifacts.uninstall).toHaveBeenCalledWith(4));
|
||||
});
|
||||
|
||||
it("installs a skill only after a confirmation, and asks before replacing", async () => {
|
||||
@@ -315,8 +407,8 @@ describe("Artifacts", () => {
|
||||
it("previews a file in a sandboxed iframe that may run nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("report.html");
|
||||
await user.click(screen.getAllByRole("button", { name: "Preview" })[0]!);
|
||||
const card = await screen.findByTestId("artifact-1");
|
||||
await user.click(within(card).getByRole("button", { name: "Preview" }));
|
||||
const frame = await screen.findByTestId("artifact-preview");
|
||||
expect(frame.getAttribute("sandbox")).toBe("");
|
||||
expect(frame.getAttribute("src")).toBe("/api/artifacts/1/raw?path=report.html");
|
||||
@@ -425,4 +517,22 @@ describe("Artifacts", () => {
|
||||
await user.click(screen.getByText("report.html"));
|
||||
expect(screen.queryByRole("button", { name: "Skill" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shelves the front page by kind, and a card opens its detail sheet", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("deploy");
|
||||
const shelves = screen.getAllByRole("heading", { level: 2 }).map((h) => h.textContent);
|
||||
expect(shelves).toEqual(["Skills1", "Files1"]);
|
||||
await user.click(screen.getAllByRole("button", { name: "See all" })[0]!);
|
||||
await waitFor(() =>
|
||||
expect(api.artifacts.list).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ kind: "skill" })
|
||||
)
|
||||
);
|
||||
await user.click(screen.getByText("deploy"));
|
||||
const sheet = await screen.findByTestId("artifact-detail");
|
||||
expect(within(sheet).getByText("/deploy")).toBeInTheDocument();
|
||||
expect(within(sheet).getByRole("button", { name: "Install" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7239,7 +7239,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.13 (rebuild client to match)
|
||||
UI build v2.8.15 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+14
-9
@@ -85,9 +85,9 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
|
||||
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
||||
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
|
||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||
|
||||
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
|
||||
|
||||
@@ -104,10 +104,11 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
|
||||
| `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) |
|
||||
| `GET /api/me/account` | Settings → Account. Server mode: `{mode: "server", user: {id, username, role, disabled, displayName, email, avatarAt, createdAt}, workspaces, sessions, logins, lastLoginAt}`; local mode `{mode: "local"}` |
|
||||
| `PATCH /api/me/account` `{displayName?, email?}` | the account's own profile (`""`/`null` clears), the same shape back; `400 EBADFIELD` (display name over 100 characters or with control characters) / `EBADEMAIL` |
|
||||
| `POST /api/me/password` `{currentPassword, newPassword}` | `{ok: true}`; every other login of the account ends, this one stays. `403 EREAUTH` wrong current password (`429 ETOOMANY` after 10), `400 EBADPASSWORD` |
|
||||
| `POST /api/me/password` `{currentPassword, newPassword}` | `{ok: true}`; every other login of the account ends, this one stays, and `mustChangePassword` clears. The new password must pass every rule — at least 8 characters, a lower-case and an upper-case letter, a digit, a symbol, not containing the username — and differ from the current one: `400 EWEAKPASSWORD` with `error.rules` (`length`, `lower`, `upper`, `digit`, `symbol`, `username`, `reused`). `403 EREAUTH` wrong current password (`429 ETOOMANY` after 10), `400 EBADPASSWORD` |
|
||||
| `POST /api/me/sign-out-others` | `{ended}` — every login of the account but this one ends |
|
||||
| `GET /api/me/avatar` | server mode: the account's own picture (`nosniff`, `sandbox` CSP; the client adds `?v=<avatarAt>`), `404 ENOAVATAR` when it has none |
|
||||
| `PUT /api/me/avatar` (raw image bytes) | the account's picture (PNG, JPEG, GIF or WebP by magic bytes — never SVG), stored as `<data dir>/avatars/u<id>.<ext>`; the `/api/me/account` shape back with `user.avatarAt` set. `400 EBADIMAGE`, `413 ETOOBIG` over 1 MB, `403 ELOCALMODE`. Login cookie only, audited |
|
||||
| `POST /api/me/avatar/url` (`{url}`) | the same picture, downloaded by the SERVER from an `https://` link (no userinfo; every resolved address — an IP literal too — must be public, never loopback/private/link-local/CGNAT/reserved, and the connection is pinned to the vetted address; ≤ 3 redirects, each re-checked; 10 s timeout; ≤ 1 MB read; no cookie sent), then checked like an upload (`server/lib/avatar-url.js`). `400 EBADURL`/`EPRIVATEURL`/`EBADIMAGE`, `413 ETOOBIG`, `502 EFETCH`, `403 ELOCALMODE`. Login cookie only, audited |
|
||||
| `DELETE /api/me/avatar` | removes it (`avatarAt` back to `null`); deleting the user removes it too |
|
||||
|
||||
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.terminal.fontSize`, `agenthub.ui.fontSize`, `agenthub.pet`, `agenthub.pet.size`, `agenthub.pet.names`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (`bypassPermissions` allowed — set only from Settings → AI tools → Session defaults, behind a confirm), `agenthub.codex.sandbox`, `agenthub.codex.approval`, `agenthub.codex.bypass` (`"true"`/`"false"`: a new Codex session's defaults), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
|
||||
@@ -2787,7 +2788,7 @@ POST /api/cc-config/mcp/add Body: { name, scope, transport?, url?,
|
||||
POST /api/cc-config/mcp/remove Body: { name, scope? }
|
||||
```
|
||||
|
||||
The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a workspace's own project commands and skills are the ones offered. In server mode a non-admin's `cwd` (on `/api/cc-config/*` and `/api/codex-config/*`) must lie inside one of their own workspaces, else `403 EFOLDER`. `GET /api/cc-config/file` and `GET /api/codex-config/file` never serve a credential file (`.credentials.json`, `auth.json`, any non-Markdown name matching the AI Tools secret pattern, anything under `<CLAUDE_HOME>/agenthub-*`): `400 READ_DENIED`.
|
||||
The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a workspace's own project commands and skills are the ones offered. `skills`, `commands` and `plugins` read their USER scope from the account the pane will run as: under S3 the `cwd` folder's account (a member's `~/.claude`, where a Hub install puts a skill), a non-admin with no `cwd` their own, an admin otherwise the host's Claude home, and a non-admin with no account of their own NOTHING (never the host's or another member's) — so a skill a developer installed shows up in their `/` list, and an admin's install into the host's home no longer shows up in a member's. In server mode a non-admin's `cwd` (on `/api/cc-config/*` and `/api/codex-config/*`) must lie inside one of their own workspaces, else `403 EFOLDER`. `GET /api/cc-config/file` and `GET /api/codex-config/file` never serve a credential file (`.credentials.json`, `auth.json`, any non-Markdown name matching the AI Tools secret pattern, anything under `<CLAUDE_HOME>/agenthub-*`): `400 READ_DENIED`.
|
||||
|
||||
`scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`.
|
||||
|
||||
@@ -2884,8 +2885,8 @@ GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narr
|
||||
GET /api/run/interrupted {items} — sessions whose pane died under them (see below), newest per session, max 50
|
||||
POST /api/run/interrupted/dismiss {sessionIds: string[]} (≤ 100) → {dismissed}: stop offering them; resumes nothing. 400 EBADFIELD otherwise
|
||||
GET /api/run/files?cwd=&q=&dirs=1 Fuzzy file search inside cwd for the @-file autocomplete (dirs=1 adds folders as `dir/`, for the + menu)
|
||||
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path
|
||||
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here. Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over 20 MB before reading the body, and close the connection; of several files only the refused ones are lost
|
||||
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path (a `claude` pane: an image as its bare path, each in a paste of its own, which Claude Code shows as `[Image #n]`; any other file as an `@` mention)
|
||||
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB; ≤2 GB from a loopback browser outside server mode, streamed to disk — Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here. Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body, and close the connection; of several files only the refused ones are lost
|
||||
POST /api/run/local-paths {paths:[<absolute>…]} (1–100) → {sizes:[bytes|null…]}: each path's size when it is a regular file on this machine, else null. The composer and the terminal use a dropped file's own `file://` path only when every size matches the dropped bytes' — "localhost" in the address bar is no proof (an SSH tunnel's browser is on another machine). 403 EREMOTE in server mode (a pane runs as an account that cannot read the host's files) and to anything but a loopback socket without X-Forwarded-For/Forwarded; 400 EBADFIELD otherwise
|
||||
GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked — for an admin, then the host's, where their host-folder workspaces drop); only a name drop-image/drop-file made → 404 otherwise
|
||||
GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
|
||||
@@ -2918,6 +2919,8 @@ Every history row carries `lane_id` (the workspace whose run it belongs to) and
|
||||
GET /api/ai-tools Catalog + detection — { tools: [...], default }
|
||||
POST /api/ai-tools/refresh Re-read the login shell's PATH, re-detect, same body
|
||||
PUT /api/ai-tools/:id Body: { enabled?: boolean, default?: true, defaultModel?: string|null } — same body
|
||||
GET /api/ai-tools/omp-sharing { available, enabled, installed, broker: {running, error?}, gateway: {running, port?, error?} } — S3 only, never a token
|
||||
PUT /api/ai-tools/omp-sharing Body: { enabled: boolean } — an admin's; 409 ENOS3 without per-user accounts; audited
|
||||
GET /api/ai-tools/:id/models { flag, models: [{ id, label, hint? }], defaultModel } — the tool's own list
|
||||
GET /api/ai-tools/:id/usage { sessions, cost } — sessions with tool = id, their tokens priced
|
||||
GET /api/ai-tools/:id/config { files: [{ index, path, format: "json"|"jsonc"|"text", exists, size }] }
|
||||
@@ -3050,20 +3053,22 @@ PATCH /api/artifacts/:id {title?, description?, shared?, s
|
||||
DELETE /api/artifacts/:id {ok, kept} — kept: a shared row of yours left your library but stays in the Hub
|
||||
GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff)
|
||||
POST /api/artifacts/:id/install {replace?} → {installed, replaced?}
|
||||
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
|
||||
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded}
|
||||
```
|
||||
|
||||
What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`):
|
||||
|
||||
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish is captured from its PostToolUse hook (the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner.
|
||||
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish is captured from its PostToolUse hook (the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned.
|
||||
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
|
||||
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `*token*`…) `403 ESECRET`.
|
||||
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
|
||||
- **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way.
|
||||
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it.
|
||||
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**. The page shows one list (`scope=all`: own rows plus shared ones) with an **All · My library · Hub** filter. Deleting a SHARED row of your own only takes it out of your library (`in_library = 0`, `inLibrary: false`, `{kept: true}`; it leaves `scope=mine` and shows as **Hub only**); unsharing such a row then deletes it (`PATCH` answers `{item, removed: true}`). Deleting an unshared row, or an admin deleting someone else's, deletes it outright.
|
||||
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**. The page shows one list (`scope=all`: own rows plus shared ones) with an **All · My library · Hub** filter. `scope=mine` is the caller's own library rows plus the shared skills/plugins of OTHER users that the caller has installed (`installed: true`, `mine: false`); in server mode the page splits EVERY tab by `mine`, others' section first — **All** into *Shared by others* / *Mine*, **Hub** into *Shared by others* / *Shared by me*, **My library** into *Installed from others* / *Mine* — and each section pages 12 cards at a time (client-side). Deleting a SHARED row of your own only takes it out of your library (`in_library = 0`, `inLibrary: false`, `{kept: true}`; it leaves `scope=mine` and shows as **Hub only**); unsharing such a row then deletes it (`PATCH` answers `{item, removed: true}`). Deleting an unshared row, or an admin deleting someone else's, deletes it outright.
|
||||
- **raw** — only a file the snapshot recorded (`404 ENOFILE` otherwise), never a request path; served with `Content-Security-Policy: …; sandbox` and `nosniff`, unknown types as an attachment.
|
||||
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list. Share, unshare, a shared row's delete and every install by someone else are written to `audit_log`.
|
||||
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list. Share, unshare, a shared row's delete and every install or uninstall by someone else are written to `audit_log`.
|
||||
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page offers **Uninstall** only in the card's ⋯ menu (and its right-click menu); there is no Reinstall — a newer version is uninstalled and installed again.
|
||||
- Every write is same-origin. The `artifact_changed` broadcast (`{id, ownerId, shared}`) reaches everyone for a shared row, else its owner and staff.
|
||||
|
||||
### Query Explorer
|
||||
|
||||
+2
-2
@@ -213,7 +213,7 @@ CREATE TABLE sessions (
|
||||
| `awaiting_reason` | TEXT | YES | Why the row is waiting: `notification`, `stop`, `session_start`, or `interrupted`. Set/cleared in lock-step with `awaiting_input_since` (SessionStart→`session_start`, Stop→`stop`, permission/input Notification→`notification`, watchdog/Esc recovery→`interrupted`). NULL otherwise. Exception: a `compact`-source SessionStart preserves the existing value (neither stamps `session_start` nor clears it) |
|
||||
| `transcript_path` | TEXT | YES | Absolute path to the session's JSONL transcript. Written by `routes/hooks.js` on the first event that carries it (subsequent events no-op via a SQL guard) and read by the periodic compaction sweep — so the sweep touches only active session rows instead of scanning the entire `events` table for `json_extract(data,'$.transcript_path')`. Backfilled once from `events` by the `db.js` migration |
|
||||
| `source` | TEXT | NO | Data source this session was captured from. `'local'` for this machine's own Claude Code history (the default); otherwise the `remote_sources.id` of the remote SSH machine it was pulled from, or `push:<name>` for a session another machine pushed to `POST /api/hooks/ingest-batch` (a pushed hook may only extend a session its own source created). Powers the `sources` query filter on `/api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics`, and the `sources` facet on `/api/sessions/facets`. Indexed by `idx_sessions_source` |
|
||||
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. `'cursor'` for a Cursor agent transcript, ingested by `server/lib/cursor-usage.js` (see `cursor_transcripts`): no `transcript_path`, no `model` and no `token_usage` (the transcripts record none), `active` while its transcript was written in the last two minutes, else `completed`. The startup cleanup of stale `active` sessions and a workspace purge touch only the matching tool's rows (a purge takes the sessions of the workspace's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
|
||||
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. `'cursor'` for a Cursor agent transcript, ingested by `server/lib/cursor-usage.js` (see `cursor_transcripts`): no `transcript_path`, no `model` and no `token_usage` (the transcripts record none); its conversation is read from the file `cursor_transcripts` recorded for it, `active` while its transcript was written in the last two minutes, else `completed`. The startup cleanup of stale `active` sessions and a workspace purge touch only the matching tool's rows (a purge takes the sessions of the workspace's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
|
||||
| `lane_id` | INTEGER | YES | The workspace that OWNS the session — the only sessions a workspace lists: the lane whose dashboard pane held it, stamped by `bindRunSession` (`server/routes/hooks.js`) from the hook's `run_id` — first writer wins, a nested `claude -p` excluded — or, for a Codex/Antigravity session, by `claimToolSessions` (`server/lib/dashboard-runs.js`) from the same-tool run that was live in its folder when it started. NULL for a session no workspace started (a terminal, an import, a tool with no hooks). No FK: removing a workspace leaves its sessions. Backfilled once from `dashboard_runs`. Indexed by `idx_sessions_lane` |
|
||||
| `archived_at` | TEXT | YES | When the user archived the session out of its workspace's lists (`PATCH /api/sessions/:id {archived}`). Hides only — nothing is deleted |
|
||||
| `pinned_at` | TEXT | YES | When the user pinned the session to the top of its workspace's sidebar list (`PATCH /api/sessions/:id {pinned}`; `GET /api/sessions?pinned_first=1` orders by it). Order only; NULL = not pinned |
|
||||
@@ -625,7 +625,7 @@ Server mode's accounts ([SERVER-MODE.md](SERVER-MODE.md), `server/lib/auth.js`).
|
||||
|
||||
`dashboard_runs.account` names the Claude pool account a run's pane was started on (`server/lib/claude-pool.js`; `NULL` = the machine's own login). The balancer sums each account's spend from the `token_usage_hourly` rows of those runs' sessions. The pool's tokens live in files under `<CLAUDE_HOME>/agenthub-accounts/`, never in the database.
|
||||
|
||||
`lanes.owner_id` (server mode) is the user who created a workspace; `NULL` for one made in local mode or by the host account. A member sees only sessions of their own workspaces, and everything hanging off them (`server/lib/scope.js`). `audit_log` (`user_id`, `owner_id`, `session_id`, `method`, `path`, `at`) records each admin/analyst read of a session a member owns.
|
||||
`lanes.owner_id` (server mode) is the user who created a workspace; `NULL` for one made in local mode or by the host account. A member sees only sessions of their own workspaces, and everything hanging off them (`server/lib/scope.js`). `audit_log` (`user_id`, `owner_id`, `session_id`, `method`, `path`, `at`) records each admin/analyst read of a session a member owns, and each admin account change; rows older than `AGENTHUB_AUDIT_DAYS` (default 180) are pruned as new ones are written. `users.last_login_at` is the account's last login (kept past its `auth_sessions` row); `users.must_change_password` (0/1) is set when an admin gives the password (create, reset) and cleared when the account picks its own.
|
||||
|
||||
`dashboard_runs.pane_token_hash` is the SHA-256 of the pane credential (`AGENTHUB_PANE_TOKEN`) a server-mode pane was started with; it is never returned by any route. `push_subscriptions.user_id` is the user whose browser a subscription is (`NULL` in local mode).
|
||||
|
||||
|
||||
+10
-3
File diff suppressed because one or more lines are too long
+69
-5
@@ -155,6 +155,10 @@ Workspaces made before server mode, or by the host account, have none.
|
||||
- An **admin** or **analyst** sees everything. Each time one of them opens a
|
||||
session a developer owns (its page, transcript, events or cost), the read is
|
||||
written to the audit log: `agenthub users audit`, or `GET /api/users/audit`.
|
||||
Entries are kept `AGENTHUB_AUDIT_DAYS` days (default 180; `0` keeps them
|
||||
all) — older ones are deleted as new ones are written, at most once an hour.
|
||||
An account's last login is kept in `users.last_login_at`, so it still shows
|
||||
after the login itself has ended.
|
||||
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
|
||||
sessions, and the tokens and $ it used in the last 7 and 30 days — the
|
||||
**Administration** page in the sidebar (`/admin`; the old `/users` link
|
||||
@@ -264,7 +268,22 @@ settings file that lacks the variable falls back to `USERS_ROOT` in
|
||||
- A login lasts 30 days in the httpOnly, `SameSite=Strict` cookie
|
||||
`agenthub_sid`. Only the token's SHA-256 is stored. Logging out, a password
|
||||
change and disabling the user each end it.
|
||||
- Passwords are stored as scrypt hashes; any non-empty password is accepted (no minimum length).
|
||||
- Passwords are stored as scrypt hashes. Two bars:
|
||||
- A password an admin **gives** someone — **Add user**, a reset on the Edit
|
||||
form, `agenthub users add` — may be anything non-empty (`123` included).
|
||||
The account is then flagged `must_change_password`: after signing in it
|
||||
sees only a "Choose your own password" page, the server answers every
|
||||
other request with `403 EPWCHANGE` (only `/auth/*`, the `/me` reads and
|
||||
`POST /api/me/password` pass) and no WebSocket opens. A pane token is not
|
||||
held back — agents started before a reset keep running.
|
||||
- A password an account **picks for itself** (that page, Settings → Account,
|
||||
an admin's own on the Edit form) must pass every rule: at least 8
|
||||
characters, a lower-case and an upper-case letter, a digit, a symbol, not
|
||||
containing the username, and not the current password (`400
|
||||
EWEAKPASSWORD`, with the failed `rules`). The form ticks each rule as it is
|
||||
typed (`client/src/lib/passwordRules.ts` mirrors `passwordProblems` in
|
||||
`server/lib/auth.js`). `agenthub users passwd` and the first admin of
|
||||
`agenthub server setup` set no flag.
|
||||
Every login attempt counts before its password is checked, so attempts sent
|
||||
in parallel count too. After 10 failed logins from one address, or 30 for one
|
||||
username, in 15 minutes, further logins answer `429 ETOOMANY` until the
|
||||
@@ -307,7 +326,10 @@ everywhere". Every field is checked before any is written, so a bad email or
|
||||
a workspace the user does not own changes nothing. Every change asks for the admin's OWN
|
||||
password again — the server refuses without it (`EREAUTH`, and `ETOOMANY`
|
||||
after 10 wrong tries in 15 minutes) — an admin can never demote or disable
|
||||
themselves (`ESELF`), and each change is written to the audit log. In local
|
||||
themselves (`ESELF`), no path (the CLI included) may demote, disable or
|
||||
delete the last enabled admin (`ELASTADMIN`), and each change is written to
|
||||
the audit log. A row says **New password due** while the account still has a
|
||||
password an admin gave. In local
|
||||
mode these routes refuse (`ELOCALMODE`): there is no login to check.
|
||||
|
||||
Wherever the dashboard names a person — the sidebar's sign-out tooltip, the
|
||||
@@ -335,10 +357,10 @@ The same from the host's shell:
|
||||
|
||||
```bash
|
||||
agenthub users list
|
||||
agenthub users add <name> [--role admin|analyst|developer]
|
||||
agenthub users add <name> [--role admin|analyst|developer] # they choose their own password at first login
|
||||
agenthub users passwd <name> # ends that user's logins
|
||||
agenthub users role <name> <role>
|
||||
agenthub users disable <name> # ends that user's logins; users are never deleted
|
||||
agenthub users role <name> <role> # never the last enabled admin (ELASTADMIN)
|
||||
agenthub users disable <name> # ends that user's logins; never the last enabled admin
|
||||
agenthub users enable <name>
|
||||
agenthub users assign <workspace id> <name> # give a pre-server-mode workspace an owner
|
||||
agenthub users audit [--limit N] # who read which developer's session
|
||||
@@ -507,6 +529,48 @@ agenthub accounts pin team-key mia --tool codex # pins work as for Claude
|
||||
> **Ceiling:** a pane can read its own account's key (`$AGENTHUB_POOL_KEY`) or
|
||||
> `auth.json`, like the Claude pool's token.
|
||||
|
||||
## Oh My Pi login sharing
|
||||
|
||||
With one Linux user per account, a member's omp has its own `~/.omp` and no
|
||||
login. An admin can let every member's omp run on the HOST account's omp
|
||||
logins instead: **Settings → AI tools → Oh My Pi login sharing** (off by
|
||||
default; `PUT /api/ai-tools/omp-sharing`, audited). It uses omp's own auth
|
||||
broker and gateway (`server/lib/omp-share.js`):
|
||||
|
||||
```bash
|
||||
# as the host account: the logins to share (omp's built-in providers only)
|
||||
omp login # e.g. anthropic
|
||||
```
|
||||
|
||||
- While it is on, the dashboard runs, as the host and on loopback only,
|
||||
`omp auth-broker serve --bind=127.0.0.1:4826` over the host's `~/.omp` store
|
||||
and `omp auth-gateway serve --bind=127.0.0.1:4827 --clients=<data dir>/omp-share/clients.json`
|
||||
(ports: `AGENTHUB_OMP_BROKER_PORT`, `AGENTHUB_OMP_GATEWAY_PORT`; logs in
|
||||
`<data dir>/omp-share/`). Both tokens stay in the host's 0700 home; the
|
||||
broker's reaches only the gateway's env. A crashed daemon restarts with a
|
||||
back-off; the server stops both on shutdown, and kills ones a previous
|
||||
server left (by pid file and argv) before starting its own.
|
||||
- A member's omp pane gets one provider `agenthub` in its
|
||||
`~/.omp/agent/models.yml` (written as the member; the rest of the file is
|
||||
kept, a file that is not a YAML mapping is left alone), pointing at the
|
||||
gateway with `apiKey: AGENTHUB_PANE_TOKEN` — the NAME of the env var holding
|
||||
its pane token — and `OMP_SKIP_SETUP=1`. It runs on `--model agenthub/<model>`:
|
||||
the model picked in AgentHub, else the host's own `modelRoles.default`
|
||||
(`~/.omp/agent/config.yml`) — left to itself omp would take the provider's
|
||||
first listed model, a retired one the API answers 404 for. The clients file lists the SHA-256
|
||||
of each LIVE omp pane's token (rewritten every 15 s; the gateway re-reads it
|
||||
every 10 s), so a token stops working shortly after its pane ends, and
|
||||
turning sharing off empties it at once.
|
||||
- A scoped client token calls models only: it cannot read a credential, the
|
||||
gateway's usage or credential health, and refresh tokens never leave the
|
||||
broker. Host panes use the host's login directly and get nothing new.
|
||||
- A custom provider in the host's own `models.yml` (a 9Router, say) is not
|
||||
shared — the gateway serves omp's built-in providers.
|
||||
|
||||
> **Ceiling:** an agent can SPEND through the shared logins while its pane
|
||||
> runs; it cannot carry a login away. A member who edits the `agenthub`
|
||||
> provider out of their `models.yml` gets it back at their next omp pane.
|
||||
|
||||
## One Linux user per account
|
||||
|
||||
Every pane of a user, an agent's shell included, runs as that user's own Linux
|
||||
|
||||
+94
-3
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.8.13
|
||||
version: 2.8.14
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -12481,6 +12481,42 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AiToolsView'
|
||||
/api/ai-tools/omp-sharing:
|
||||
get:
|
||||
tags:
|
||||
- AI Tools
|
||||
summary: Oh My Pi login sharing state
|
||||
description: 'Server mode with per-user accounts (S3): whether every member''s omp pane runs on the HOST account''s omp login through omp''s own auth broker + gateway on loopback (server/lib/omp-share.js), and whether both daemons run. Never returns a token.'
|
||||
operationId: aiToolsOmpSharing
|
||||
responses:
|
||||
'200':
|
||||
description: '`{available, enabled, installed, broker: {running, error?}, gateway: {running, port?, error?}}`.'
|
||||
put:
|
||||
tags:
|
||||
- AI Tools
|
||||
summary: Turn Oh My Pi login sharing on or off
|
||||
description: An admin's switch (off by default). On starts `omp auth-broker serve` and `omp auth-gateway serve --clients=…` as the host; off stops them and empties the clients file, so no pane keeps a way in. Same-origin only; audited.
|
||||
operationId: aiToolsSetOmpSharing
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- enabled
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
responses:
|
||||
'200':
|
||||
description: The new state, as GET.
|
||||
'400':
|
||||
description: EBADFIELD
|
||||
'403':
|
||||
description: Not an admin
|
||||
'409':
|
||||
description: ENOS3 — not server mode with per-user accounts
|
||||
/api/ai-tools/{id}:
|
||||
put:
|
||||
tags:
|
||||
@@ -14498,7 +14534,7 @@ paths:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: changeMyPassword
|
||||
summary: 'Change the account''s own password: the current one required (throttled); this login stays, every other ends'
|
||||
summary: 'Change the account''s own password: the current one required (throttled); the new one must pass every rule (8+ characters, lower, upper, digit, symbol, not the username) and differ from the current one; this login stays, every other ends; clears mustChangePassword'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -14518,7 +14554,7 @@ paths:
|
||||
'200':
|
||||
description: 'Changed ({ok: true})'
|
||||
'400':
|
||||
description: EBADPASSWORD
|
||||
description: EBADPASSWORD / EWEAKPASSWORD (error.rules names the failed rules)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -14844,6 +14880,61 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/me/avatar/url:
|
||||
post:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: setMyAvatarFromUrl
|
||||
summary: Set the account's picture from an https link the server downloads (public addresses only, at most 3 redirects, PNG/JPEG/GIF/WebP, at most 1 MB). Login cookie only, audited
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- url
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
format: uri
|
||||
responses:
|
||||
'200':
|
||||
description: Set
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MyAccount'
|
||||
'400':
|
||||
description: EBADURL / EPRIVATEURL / EBADIMAGE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: EAUTH — no login cookie (a pane token is refused)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'413':
|
||||
description: ETOOBIG
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'502':
|
||||
description: EFETCH — the link could not be downloaded
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}/avatar:
|
||||
get:
|
||||
tags:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.13",
|
||||
"version": "2.8.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.13",
|
||||
"version": "2.8.15",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.13",
|
||||
"version": "2.8.15",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
+10
-3
@@ -632,12 +632,19 @@ An `antigravity` workspace carries no pipeline either.
|
||||
per-conversation SQLite files (`~/.gemini/antigravity-cli/conversations/<id>.db`)
|
||||
every `DASHBOARD_ANTIGRAVITY_POLL_MS` ms (default 15000, `0` disables). They
|
||||
become `sessions` rows (`tool = 'antigravity'`) and `token_usage` rows, and
|
||||
`antigravity_conversations` holds the mtime read for each file.
|
||||
`antigravity_conversations` holds the mtime read for each file. The
|
||||
session's conversation (`GET /api/sessions/:id/transcript`) is read from its
|
||||
step log (`brain/<id>/.system_generated/logs/transcript_full.jsonl`,
|
||||
`transcriptPath`): prompts, the model's text, thinking, tool calls and
|
||||
tokens, and each tool's result tied to its call by step.
|
||||
- **Cursor sessions:** `server/lib/cursor-usage.js` reads Cursor's agent
|
||||
transcripts (`~/.cursor/projects/*/agent-transcripts/<id>/<id>.jsonl`) every
|
||||
`DASHBOARD_CURSOR_POLL_MS` ms (default 15000, `0` disables) into `sessions`
|
||||
rows (`tool = 'cursor'`) — no `token_usage`, the transcripts carry none —
|
||||
and `cursor_transcripts` holds the mtime read for each file.
|
||||
and `cursor_transcripts` holds the mtime read for each file. The session's
|
||||
conversation (`GET /api/sessions/:id/transcript`) is read from that recorded
|
||||
file: prompts, replies, thinking, tool calls and results, with no timestamps
|
||||
or model (Cursor writes none).
|
||||
- **Oh My Pi sessions:** `server/lib/omp-usage.js` reads omp's session journals
|
||||
(`~/.omp/agent/sessions/<encoded cwd>/<timestamp>_<id>.jsonl`, plus every
|
||||
subagent journal under `<timestamp>_<id>/`) every `DASHBOARD_OMP_POLL_MS` ms
|
||||
@@ -803,7 +810,7 @@ HTTP surface for the tmux-backed terminal run: one session per workspace, named
|
||||
|
||||
Codex-only spawn options (`sandbox`, `approval`, `resumeLast`) are accepted by the workspace action `POST /api/lanes/:id/start`, not by `POST /api/run` — see [`docs/API.md`](../docs/API.md).
|
||||
|
||||
The pane itself is not JSON: its bytes stream over `/ws-pty/:runId` as binary WebSocket frames. The JSON WebSocket surface adds `cc_config_changed` (broadcast by `lib/cc-watcher.js` on `fs.watch` events under `~/.claude/` and by `routes/cc-config.js` after every successful PUT/DELETE — debounced at 500 ms, payload `{ source: "dashboard"|"fs", action?, scope?, type?, name?, paths? }`) and its Codex counterpart `codex_config_changed` (broadcast by `routes/codex-config.js` after every successful file mutation and CLI-backed mcp/plugin/marketplace action — always `source: "dashboard"`, there is no `$CODEX_HOME` filesystem watcher).
|
||||
The pane itself is not JSON: its bytes stream over `/ws-pty/:runId` as binary WebSocket frames. The JSON WebSocket surface adds `cc_config_changed` (broadcast by `lib/cc-watcher.js` on `fs.watch` events under `~/.claude/` and by `routes/cc-config.js` after every successful PUT/DELETE — debounced at 500 ms, payload `{ source: "dashboard"|"fs", action?, scope?, type?, name?, paths? }`) and its Codex counterpart `codex_config_changed` (broadcast by `routes/codex-config.js` after every successful file mutation and CLI-backed mcp/plugin/marketplace action — always `source: "dashboard"`, there is no `$CODEX_HOME` filesystem watcher), and `transcript_changed` `{ session_id }` — the session sync's `fs.watch` on the projects tree saw a Claude transcript grow (`pushTranscript` in `server/index.js`, at most every 250 ms per session; nothing is parsed for it), so a chat showing that session reads its new lines at once instead of at its next 3 s poll. It reaches whoever the session's own `new_event` reaches (`broadcastAudience`).
|
||||
|
||||
### Import History
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @file antigravity-transcript.test.js
|
||||
* @description An Antigravity (agy) session's conversation view: its
|
||||
* `transcript_full.jsonl` step log mapped to the Claude transcript shape —
|
||||
* the prompt out of `<USER_REQUEST>`, the model's thinking, text, tool calls
|
||||
* and tokens, each tool result tied to its call by step and an invalid call
|
||||
* marked an error — one "main" transcript, no subagent files, and a session
|
||||
* id that is no UUID finding nothing. The agy dir and the DB are temp dirs;
|
||||
* the steps are synthetic, in the shape agy 1.4 writes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const os = require("node:os");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-agy-transcript-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||
process.env.AGENTHUB_GEMINI_DIR = path.join(ROOT, "gemini");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
|
||||
const { describe, it, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { db } = require("../db");
|
||||
const agy = require("../lib/antigravity-usage");
|
||||
|
||||
let server;
|
||||
after(() => {
|
||||
server?.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const ID = "57154e1a-65e5-4b51-a116-1890f5ca831f";
|
||||
const AT = "2026-10-21T05:08:24Z";
|
||||
|
||||
function steps(id) {
|
||||
const dir = path.join(
|
||||
process.env.AGENTHUB_GEMINI_DIR,
|
||||
"antigravity-cli",
|
||||
"brain",
|
||||
id,
|
||||
".system_generated",
|
||||
"logs"
|
||||
);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, "transcript_full.jsonl");
|
||||
const lines = [
|
||||
{
|
||||
step_index: 0,
|
||||
source: "USER_EXPLICIT",
|
||||
type: "USER_INPUT",
|
||||
status: "DONE",
|
||||
created_at: AT,
|
||||
content:
|
||||
"<USER_REQUEST>\nCreate c.txt\n</USER_REQUEST>\n<ADDITIONAL_METADATA>\nThe current local time is: x.\n</ADDITIONAL_METADATA>",
|
||||
},
|
||||
{
|
||||
step_index: 1,
|
||||
source: "MODEL",
|
||||
type: "PLANNER_RESPONSE",
|
||||
status: "DONE",
|
||||
created_at: AT,
|
||||
input_tokens: 3889,
|
||||
cache_read_tokens: 768,
|
||||
output_tokens: 12,
|
||||
thinking: "Two commands.",
|
||||
content: "Running them now.",
|
||||
tool_calls: [
|
||||
{ name: "run_command", args: { CommandLine: "echo ok > c.txt" } },
|
||||
{ name: "read", args: { path: "c.txt" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
step_index: 2,
|
||||
source: "MODEL",
|
||||
type: "GENERIC",
|
||||
status: "DONE",
|
||||
created_at: AT,
|
||||
content: "Created At: x\nCompleted At: y\n\nThe command exited with code 0.\n",
|
||||
},
|
||||
{
|
||||
step_index: 3,
|
||||
source: "MODEL",
|
||||
type: "GENERIC",
|
||||
status: "INVALID",
|
||||
error: 'unknown tool: "read"',
|
||||
created_at: AT,
|
||||
content: 'Created At: x\nCompleted At: y\nError invalid tool call: unknown tool: "read"',
|
||||
},
|
||||
{
|
||||
step_index: 4,
|
||||
source: "MODEL",
|
||||
type: "PLANNER_RESPONSE",
|
||||
status: "DONE",
|
||||
created_at: AT,
|
||||
content: "Done.",
|
||||
},
|
||||
];
|
||||
fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join("\n") + "\n");
|
||||
return file;
|
||||
}
|
||||
|
||||
describe("an Antigravity session's conversation", () => {
|
||||
it("reads agy's step log in the Claude transcript shape", async () => {
|
||||
const file = steps(ID);
|
||||
assert.equal(agy.transcriptPath(ID), file);
|
||||
assert.equal(agy.transcriptPath("../../etc"), null);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, started_at, tool) VALUES (?, 'c', 'completed', ?, 'antigravity')"
|
||||
).run(ID, AT);
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
server ||= await startServer(createApp(), 0);
|
||||
const BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
|
||||
assert.deepEqual(
|
||||
list.transcripts.map((t) => [t.id, t.type]),
|
||||
[["main", "main"]]
|
||||
);
|
||||
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
|
||||
assert.deepEqual(
|
||||
body.messages.map((m) => [m.sender, m.content.map((c) => c.type)]),
|
||||
[
|
||||
["user", ["text"]],
|
||||
["assistant", ["thinking", "text", "tool_use", "tool_use"]],
|
||||
["tool", ["tool_result"]],
|
||||
["tool", ["tool_result"]],
|
||||
["assistant", ["text"]],
|
||||
]
|
||||
);
|
||||
const [user, plan, ok, bad] = body.messages;
|
||||
assert.equal(user.content[0].text, "Create c.txt");
|
||||
assert.equal(user.timestamp, AT);
|
||||
// Each result names the call it answers: call i of step s lands at step s+1+i.
|
||||
assert.deepEqual(
|
||||
plan.content.filter((c) => c.type === "tool_use").map((c) => c.id),
|
||||
[ok.content[0].id, bad.content[0].id]
|
||||
);
|
||||
assert.equal(ok.content[0].output, "The command exited with code 0.\n");
|
||||
assert.equal(ok.content[0].is_error, false);
|
||||
assert.equal(bad.content[0].is_error, true);
|
||||
assert.equal(plan.usage.input_tokens, 3889);
|
||||
assert.equal(plan.usage.cache_read_input_tokens, 768);
|
||||
const sub = await (await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=x`)).json();
|
||||
assert.equal(sub.messages.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
* claude.ai pages (link allowlist, hook capture, one row per URL), workspace
|
||||
* file candidates and pins (a snapshot, served sandboxed), skills (snapshot,
|
||||
* symlink/secret refusal, install into the caller's ~/.claude and ~/.agents skills with
|
||||
* replace moving the old one aside), plugins (marketplace allowlist, install
|
||||
* replace and uninstall moving the old one aside), plugins (marketplace allowlist, install
|
||||
* argv; a marketplace link read into its plugin list, the same plugin added
|
||||
* twice kept as one row, key/market edited), skills uploaded (one Markdown
|
||||
* file, a folder, a zip, several skills at once — name and frontmatter set,
|
||||
@@ -119,6 +119,34 @@ describe("pages", () => {
|
||||
assert.equal(artifacts.captureFromHook("s1", { tool_input: { action: "read" } }), null);
|
||||
assert.equal(artifacts.captureFromHook("s1", { tool_response: "no link" }), null);
|
||||
});
|
||||
|
||||
it("gives a page from a session no workspace owns to the member who sent the hook", () => {
|
||||
const uid = db
|
||||
.prepare("INSERT INTO users (username, password_hash) VALUES ('ann', 'x')")
|
||||
.run().lastInsertRowid;
|
||||
const hook = (url) => ({
|
||||
tool_input: { action: "publish" },
|
||||
tool_response: `Published: ${url}`,
|
||||
});
|
||||
const own = artifacts.captureFromHook("loose-1", hook("https://claude.ai/artifact/m1"), {
|
||||
kind: "member",
|
||||
name: "ann",
|
||||
});
|
||||
assert.equal(own.owner_id, Number(uid));
|
||||
assert.equal(own.lane_id, null);
|
||||
// The host account names nobody; a workspace's owner still wins over the peer.
|
||||
const host = artifacts.captureFromHook("loose-2", hook("https://claude.ai/artifact/m2"), {
|
||||
kind: "host",
|
||||
});
|
||||
assert.equal(host.owner_id, null);
|
||||
const ws = artifacts.captureFromHook("s1", hook("https://claude.ai/artifact/m3"), {
|
||||
kind: "member",
|
||||
name: "ann",
|
||||
});
|
||||
const laneOwner = db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(lane.id).owner_id;
|
||||
assert.equal(ws.owner_id, laneOwner);
|
||||
assert.equal(ws.lane_id, lane.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("files", () => {
|
||||
@@ -145,6 +173,12 @@ describe("files", () => {
|
||||
assert.match(raw.text, /<h1>hi<\/h1>/);
|
||||
assert.match(raw.headers.get("content-security-policy"), /sandbox/);
|
||||
assert.equal(raw.headers.get("x-content-type-options"), "nosniff");
|
||||
// Deleting the source on disk takes nothing away: only a delete in AgentHub does.
|
||||
const live = path.join(ws, "out", "report.html");
|
||||
fs.rmSync(live);
|
||||
assert.equal((await request("GET", `/api/artifacts/${id}`)).status, 200);
|
||||
assert.match((await request("GET", `/api/artifacts/${id}/raw`)).text, /<h1>hi<\/h1>/);
|
||||
fs.writeFileSync(live, "changed");
|
||||
const other = await request("GET", `/api/artifacts/${id}/raw?path=../../t.db`);
|
||||
assert.equal(other.status, 404);
|
||||
|
||||
@@ -160,6 +194,32 @@ describe("files", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("my library", () => {
|
||||
it("adds the shared skills of others the caller installed, and only those", () => {
|
||||
const ins = db.prepare(
|
||||
"INSERT INTO artifacts (kind, title, owner_id, source, shared) VALUES ('skill', ?, ?, ?, ?)"
|
||||
);
|
||||
const theirs = Number(ins.run("theirs", 77, "lib-theirs", 1).lastInsertRowid);
|
||||
const notInstalled = Number(ins.run("other", 77, "lib-other", 1).lastInsertRowid);
|
||||
const unshared = Number(ins.run("private", 77, "lib-private", 0).lastInsertRowid);
|
||||
const own = Number(ins.run("own", 5, "lib-own", 0).lastInsertRowid);
|
||||
for (const name of ["lib-theirs", "lib-private"])
|
||||
fs.mkdirSync(path.join(process.env.CLAUDE_HOME, "skills", name), { recursive: true });
|
||||
const req = { user: { id: 5, role: "admin" } };
|
||||
const ids = artifacts.list(req, { scope: "mine", kind: "skill" }).map((a) => [a.id, a.mine]);
|
||||
assert.deepEqual(
|
||||
ids.filter(([id]) => [theirs, notInstalled, unshared, own].includes(id)).sort(),
|
||||
[
|
||||
[theirs, false],
|
||||
[own, true],
|
||||
].sort()
|
||||
);
|
||||
db.prepare(
|
||||
`DELETE FROM artifacts WHERE id IN (${[theirs, notInstalled, unshared, own].join(",")})`
|
||||
).run();
|
||||
});
|
||||
});
|
||||
|
||||
describe("skills", () => {
|
||||
it("snapshots a workspace skill and installs it into ~/.claude/skills and ~/.agents/skills", async () => {
|
||||
const list = await request("GET", `/api/artifacts/skills?laneId=${lane.id}`);
|
||||
@@ -200,6 +260,18 @@ describe("skills", () => {
|
||||
const old = fs.readdirSync(aside).find((n) => !n.includes("-agents-"));
|
||||
assert.equal(fs.readFileSync(path.join(aside, old, "mine.txt"), "utf8"), "keep me");
|
||||
assert.ok(fs.readdirSync(aside).some((n) => n.startsWith("deploy-agents-")));
|
||||
|
||||
// Uninstall moves both copies aside too, never deletes them.
|
||||
const gone = await request("POST", `/api/artifacts/${id}/uninstall`, {});
|
||||
assert.equal(gone.status, 200, gone.text);
|
||||
assert.deepEqual(gone.body.paths, [target, shared]);
|
||||
assert.ok(!fs.existsSync(target) && !fs.existsSync(shared));
|
||||
assert.equal((await listed()).installed, false);
|
||||
const kept = fs.readdirSync(aside).filter((n) => n.includes("-uninstalled-"));
|
||||
assert.equal(kept.length, 2);
|
||||
const none = await request("POST", `/api/artifacts/${id}/uninstall`, {});
|
||||
assert.equal(none.status, 409);
|
||||
assert.equal(none.body.error.code, "ENOTINSTALLED");
|
||||
});
|
||||
|
||||
it("edits a skill's name, description and instructions into a new snapshot", async () => {
|
||||
@@ -271,6 +343,12 @@ describe("plugins", () => {
|
||||
["claude", "plugin", "marketplace", "add", "o/r"],
|
||||
["claude", "plugin", "install", "--scope", "user", "tool@market"],
|
||||
]);
|
||||
calls.length = 0;
|
||||
const un = await request("POST", `/api/artifacts/${r.body.item.id}/uninstall`, {});
|
||||
assert.equal(un.status, 200, un.text);
|
||||
assert.deepEqual(calls, [
|
||||
["claude", "plugin", "uninstall", "--scope", "user", "--keep-data", "tool@market"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
* project slug walked back onto the filesystem) and no tokens, an unchanged
|
||||
* file never re-read, a quiet one settled to completed, a session the user
|
||||
* deleted staying deleted, and a non-cursor row with the same id never
|
||||
* touched. The Cursor dir and the DB are temp dirs; the transcripts are
|
||||
* synthetic, in the shape Cursor writes.
|
||||
* touched, a session claimed by the Cursor pane it started in, and the
|
||||
* transcript mapped to the Claude transcript shape by the
|
||||
* transcript routes. The Cursor dir and the DB are temp dirs; the transcripts
|
||||
* are synthetic, in the shape Cursor writes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -16,13 +18,18 @@ const path = require("node:path");
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cursor-usage-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||
process.env.AGENTHUB_CURSOR_DIR = path.join(ROOT, "cursor");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { db } = require("../db");
|
||||
const cursor = require("../lib/cursor-usage");
|
||||
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
let server;
|
||||
after(() => {
|
||||
server?.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const ID = "7481e37e-9157-4145-b985-2ea9a2117f52";
|
||||
const PROJECT = path.join(fs.realpathSync(ROOT), "work", "my app");
|
||||
@@ -65,7 +72,8 @@ const session = (id) => db.prepare("SELECT * FROM sessions WHERE id = ?").get(id
|
||||
beforeEach(() => {
|
||||
fs.rmSync(process.env.AGENTHUB_CURSOR_DIR, { recursive: true, force: true });
|
||||
db.exec(
|
||||
"DELETE FROM token_usage; DELETE FROM agents; DELETE FROM sessions; DELETE FROM cursor_transcripts;"
|
||||
"DELETE FROM token_usage; DELETE FROM agents; DELETE FROM sessions; DELETE FROM cursor_transcripts;" +
|
||||
" DELETE FROM dashboard_runs; DELETE FROM lanes;"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -175,4 +183,64 @@ describe("cursor usage", () => {
|
||||
assert.equal(session(ID).name, `Cursor ${ID.slice(0, 8)}`);
|
||||
assert.equal(session(ID).cwd, null);
|
||||
});
|
||||
|
||||
it("maps the transcript to the transcript shape", async () => {
|
||||
const file = transcript(ID);
|
||||
const more = [
|
||||
{
|
||||
role: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "Reading it now." },
|
||||
{ type: "tool_use", id: "t1", name: "Grep", input: { pattern: "x" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
message: { content: [{ type: "tool_result", tool_use_id: "t1", content: "no match" }] },
|
||||
},
|
||||
{ role: "assistant", message: { content: [{ type: "text", text: "Done." }] } },
|
||||
];
|
||||
fs.appendFileSync(file, more.map((l) => JSON.stringify(l)).join("\n") + "\n");
|
||||
await cursor.ingestCursorUsage();
|
||||
assert.equal(cursor.transcriptPath(ID), file);
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
server ||= await startServer(createApp(), 0);
|
||||
const BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
|
||||
assert.deepEqual(
|
||||
list.transcripts.map((t) => [t.id, t.type]),
|
||||
[["main", "main"]]
|
||||
);
|
||||
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
|
||||
assert.deepEqual(
|
||||
body.messages.map((m) => [m.sender, m.content.map((c) => c.type)]),
|
||||
[
|
||||
["user", ["text"]],
|
||||
["assistant", ["tool_use"]],
|
||||
["assistant", ["text", "tool_use"]],
|
||||
["tool", ["tool_result"]],
|
||||
["assistant", ["text"]],
|
||||
]
|
||||
);
|
||||
assert.equal(body.messages[0].content[0].text, "Review the campaign code\nplease");
|
||||
assert.equal(body.messages[3].content[0].output, "no match");
|
||||
const sub = await (await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=x`)).json();
|
||||
assert.equal(sub.messages.length, 0);
|
||||
});
|
||||
|
||||
it("gives the session to the Cursor pane it started in", async () => {
|
||||
db.prepare("INSERT INTO lanes (id, title, cwd) VALUES (5, 'w', ?)").run(PROJECT);
|
||||
db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, lane_id, cwd, status, started_at, tool)
|
||||
VALUES ('agenthub-lane-5', 5, ?, 'running', ?, 'cursor')`
|
||||
).run(PROJECT, new Date(Date.now() - 60_000).toISOString());
|
||||
transcript(ID);
|
||||
await cursor.ingestCursorUsage();
|
||||
assert.equal(session(ID).lane_id, 5);
|
||||
const run = db.prepare("SELECT session_id FROM dashboard_runs WHERE id = 'agenthub-lane-5'");
|
||||
assert.equal(run.get().session_id, ID);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,6 +205,9 @@ describe("lane-sources instructions and launch", () => {
|
||||
assert.equal(argv[5], "--permission-mode");
|
||||
assert.ok(!argv.some((a) => a.startsWith("--append-system-prompt")));
|
||||
assert.ok(newSession.includes("CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1"));
|
||||
// Claude Code hides its task tools from newer models without this, and the
|
||||
// chat Progress panel is drawn from those calls.
|
||||
assert.ok(newSession.includes("CLAUDE_CODE_ENABLE_TODO_TOOLS=1"));
|
||||
assert.deepEqual(fs.readdirSync(dir), ["CLAUDE.md"]);
|
||||
const text = fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8");
|
||||
assert.match(text, /^- always answer in Vietnamese/);
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* served with `nosniff` and a `sandbox` CSP; `avatarAt` reaches `/api/auth/me`;
|
||||
* another account never sees it through `/api/me`; staff read it at
|
||||
* `/api/users/:id/avatar` (a developer is refused) and a workspace carries
|
||||
* `owner_avatar_at` for the sidebar's owner groups.
|
||||
* `owner_avatar_at` for the sidebar's owner groups. A picture from a link
|
||||
* (`POST /api/me/avatar/url`, server/lib/avatar-url.js) takes https only and
|
||||
* never reaches a loopback/private address.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -108,6 +110,51 @@ it("refuses an SVG, an empty body, a too-large image and no login", async () =>
|
||||
assert.equal((await put("", PNG)).status, 401);
|
||||
});
|
||||
|
||||
it("a picture from a link: https only, never a private or local address", async () => {
|
||||
const ava = await login("ava", "ava-pass-1");
|
||||
const post = (url) =>
|
||||
fetch(BASE + "/api/me/avatar/url", {
|
||||
method: "POST",
|
||||
headers: { cookie: ava, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
for (const [url, code] of [
|
||||
["not a url", "EBADURL"],
|
||||
["http://example.com/a.png", "EBADURL"],
|
||||
["https://user:pw@example.com/a.png", "EBADURL"],
|
||||
["https://127.0.0.1/a.png", "EPRIVATEURL"],
|
||||
["https://[::1]/a.png", "EPRIVATEURL"],
|
||||
["https://localhost/a.png", "EPRIVATEURL"],
|
||||
["https://169.254.169.254/latest", "EPRIVATEURL"],
|
||||
]) {
|
||||
const res = await post(url);
|
||||
assert.equal(res.status, 400, url);
|
||||
assert.equal((await res.json()).error.code, code, url);
|
||||
}
|
||||
assert.equal((await post("")).status, 400);
|
||||
|
||||
const { isPublicAddress } = require("../lib/avatar-url");
|
||||
for (const a of [
|
||||
"10.1.2.3",
|
||||
"172.20.0.1",
|
||||
"192.168.1.1",
|
||||
"100.64.0.1",
|
||||
"0.0.0.0",
|
||||
"::",
|
||||
"fe80::1",
|
||||
"fd00::1",
|
||||
"::ffff:127.0.0.1",
|
||||
"::ffff:7f00:1",
|
||||
"2002:7f00::1",
|
||||
"ff02::1",
|
||||
]) {
|
||||
assert.equal(isPublicAddress(a), false, a);
|
||||
}
|
||||
for (const a of ["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111", "::ffff:8.8.8.8"]) {
|
||||
assert.equal(isPublicAddress(a), true, a);
|
||||
}
|
||||
});
|
||||
|
||||
it("staff read another account's avatar; workspaces carry owner_avatar_at", async () => {
|
||||
const ava = await login("ava", "ava-pass-1");
|
||||
const ann = await login("ann", "ann-pass-1");
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @file omp-share.test.js
|
||||
* @description Oh My Pi login sharing (server/lib/omp-share.js): a member's
|
||||
* `models.yml` gets one `agenthub` provider pointing at the loopback gateway
|
||||
* with the pane token's env var NAME as its key (the rest of the file kept, a
|
||||
* non-mapping refused); the gateway's clients file holds only LIVE omp panes'
|
||||
* token hashes; a spawn is untouched while sharing is off, for a host pane, or
|
||||
* without S3; and the route refuses outside S3. No omp daemon is started.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "omp-share-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.LANES_ROOT = path.join(TMP, "lanes");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
process.env.AGENTHUB_OMP_GATEWAY_PORT = "4999";
|
||||
|
||||
const yaml = require("js-yaml");
|
||||
const { createApp, startServer } = require("../index");
|
||||
const share = require("../lib/omp-share");
|
||||
const linux = require("../lib/linux-accounts");
|
||||
const runAs = require("../lib/run-as");
|
||||
const tmux = require("../lib/tmux");
|
||||
const { db } = require("../db");
|
||||
|
||||
const realS3 = linux.s3Enabled;
|
||||
const realLive = tmux.liveSessions;
|
||||
const flag = path.join(TMP, "data", "omp-share", "enabled");
|
||||
const clientsFile = path.join(TMP, "data", "omp-share", "clients.json");
|
||||
|
||||
function turnOn() {
|
||||
fs.mkdirSync(path.dirname(flag), { recursive: true });
|
||||
fs.writeFileSync(flag, "");
|
||||
linux.s3Enabled = () => true;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
linux.s3Enabled = realS3;
|
||||
tmux.liveSessions = realLive;
|
||||
fs.rmSync(flag, { force: true });
|
||||
});
|
||||
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
describe("mergedModels", () => {
|
||||
it("adds the provider to an empty or missing file", () => {
|
||||
const doc = yaml.load(share.mergedModels(""));
|
||||
assert.deepEqual(doc, {
|
||||
providers: {
|
||||
agenthub: {
|
||||
baseUrl: "http://127.0.0.1:4999",
|
||||
apiKey: "AGENTHUB_PANE_TOKEN",
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
discovery: { type: "proxy" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the member's own providers and settings", () => {
|
||||
const text =
|
||||
"equivalence:\n x: y\nproviders:\n mine:\n baseUrl: http://h/v1\n apiKey: K\n";
|
||||
const doc = yaml.load(share.mergedModels(text));
|
||||
assert.equal(doc.providers.mine.baseUrl, "http://h/v1");
|
||||
assert.deepEqual(doc.equivalence, { x: "y" });
|
||||
assert.equal(doc.providers.agenthub.apiKey, "AGENTHUB_PANE_TOKEN");
|
||||
});
|
||||
|
||||
it("writes nothing when the provider is already there as is", () => {
|
||||
const once = share.mergedModels("");
|
||||
assert.equal(share.mergedModels(once), null);
|
||||
});
|
||||
|
||||
it("replaces a stale provider of ours", () => {
|
||||
const stale = "providers:\n agenthub:\n baseUrl: http://127.0.0.1:1\n";
|
||||
assert.equal(
|
||||
yaml.load(share.mergedModels(stale)).providers.agenthub.baseUrl,
|
||||
"http://127.0.0.1:4999"
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a file that is not a mapping", () => {
|
||||
assert.throws(() => share.mergedModels("- a\n- b\n"), /mapping/);
|
||||
assert.throws(() => share.mergedModels("providers: [1]\n"), /mapping/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shareModel", () => {
|
||||
const host = "anthropic/claude-sonnet-5-5";
|
||||
it("runs on the host's default when none was asked for", () => {
|
||||
assert.equal(share.shareModel(null, host), "agenthub/anthropic/claude-sonnet-5-5");
|
||||
});
|
||||
it("maps a requested model onto the gateway's provider", () => {
|
||||
assert.equal(share.shareModel("claude-opus-5-5", host), "agenthub/anthropic/claude-opus-5-5");
|
||||
assert.equal(share.shareModel("openai-codex/gpt-5.5", host), "agenthub/openai-codex/gpt-5.5");
|
||||
assert.equal(share.shareModel("agenthub/anthropic/x", host), "agenthub/anthropic/x");
|
||||
});
|
||||
it("leaves the choice to omp when there is nothing to go on", () => {
|
||||
assert.equal(share.shareModel(null, null), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("forSpawn", () => {
|
||||
const member = { login: "ah-mia", home: path.join(TMP, "home-mia") };
|
||||
|
||||
it("is null while sharing is off, for a host pane, or without S3", () => {
|
||||
assert.equal(share.forSpawn(member, "agenthub-lane-1", "h"), null); // off
|
||||
fs.mkdirSync(path.dirname(flag), { recursive: true });
|
||||
fs.writeFileSync(flag, "");
|
||||
assert.equal(share.forSpawn(member, "agenthub-lane-1", "h"), null); // no S3
|
||||
linux.s3Enabled = () => true;
|
||||
assert.equal(share.forSpawn(null, "agenthub-lane-1", "h"), null); // the host's
|
||||
assert.equal(share.forSpawn(member, "agenthub-lane-1", null), null); // no token
|
||||
});
|
||||
|
||||
it("writes the member's models.yml AS them and lists the pane's token", () => {
|
||||
turnOn();
|
||||
const writes = [];
|
||||
const real = { read: runAs.readFileSafe, mkdir: runAs.mkdirAs, write: runAs.writeAs };
|
||||
runAs.readFileSafe = () => {
|
||||
const e = new Error("missing");
|
||||
e.code = "ENOENT";
|
||||
throw e;
|
||||
};
|
||||
runAs.mkdirAs = (acct, dir) => writes.push(["mkdir", acct.login, dir]);
|
||||
runAs.writeAs = (acct, file, mode, content) => writes.push([mode, acct.login, file, content]);
|
||||
tmux.liveSessions = () => new Set();
|
||||
try {
|
||||
const s = share.forSpawn(member, "agenthub-lane-7", "a".repeat(64));
|
||||
assert.deepEqual(s.env, { OMP_SKIP_SETUP: "1" });
|
||||
s.place();
|
||||
const agentDir = path.join(member.home, ".omp", "agent");
|
||||
assert.deepEqual(writes[0], ["mkdir", "ah-mia", agentDir]);
|
||||
assert.equal(writes[1][0], "replace");
|
||||
assert.equal(writes[1][2], path.join(agentDir, "models.yml"));
|
||||
assert.equal(yaml.load(writes[1][3]).providers.agenthub.apiKey, "AGENTHUB_PANE_TOKEN");
|
||||
const clients = JSON.parse(fs.readFileSync(clientsFile, "utf8"));
|
||||
assert.deepEqual(clients["agenthub-lane-7"], { tokenSha256: "a".repeat(64) });
|
||||
assert.equal((fs.statSync(clientsFile).mode & 0o777).toString(8), "600");
|
||||
} finally {
|
||||
runAs.readFileSafe = real.read;
|
||||
runAs.mkdirAs = real.mkdir;
|
||||
runAs.writeAs = real.write;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveClients", () => {
|
||||
before(() => {
|
||||
const ins = db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, cwd, status, tool, pane_token_hash) VALUES (?, '/x', ?, ?, ?)`
|
||||
);
|
||||
ins.run("agenthub-lane-21", "running", "omp", "b".repeat(64));
|
||||
ins.run("agenthub-lane-22", "running", "omp", "c".repeat(64)); // pane gone
|
||||
ins.run("agenthub-lane-23", "ended", "omp", "d".repeat(64));
|
||||
ins.run("agenthub-lane-24", "running", "claude", "e".repeat(64));
|
||||
});
|
||||
|
||||
it("lists only live omp panes", () => {
|
||||
tmux.liveSessions = () => new Set(["agenthub-lane-21", "agenthub-lane-23", "agenthub-lane-24"]);
|
||||
const c = share.liveClients();
|
||||
assert.deepEqual(c["agenthub-lane-21"], { tokenSha256: "b".repeat(64) });
|
||||
for (const id of ["agenthub-lane-22", "agenthub-lane-23", "agenthub-lane-24"]) {
|
||||
assert.equal(c[id], undefined, id);
|
||||
}
|
||||
});
|
||||
|
||||
it("says nothing when tmux could not be asked", () => {
|
||||
tmux.liveSessions = () => null;
|
||||
assert.equal(share.liveClients(), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/ai-tools/omp-sharing", () => {
|
||||
let server;
|
||||
let BASE;
|
||||
before(async () => {
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
after(() => server.close());
|
||||
|
||||
it("reports it unavailable and refuses the switch outside S3", async () => {
|
||||
const got = await (await fetch(`${BASE}/api/ai-tools/omp-sharing`)).json();
|
||||
assert.equal(got.available, false);
|
||||
assert.equal(got.enabled, false);
|
||||
const put = await fetch(`${BASE}/api/ai-tools/omp-sharing`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json", origin: BASE },
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
});
|
||||
assert.equal(put.status, 409);
|
||||
assert.equal((await put.json()).error.code, "ENOS3");
|
||||
assert.equal(fs.existsSync(flag), false);
|
||||
});
|
||||
|
||||
it("wants a boolean", async () => {
|
||||
const put = await fetch(`${BASE}/api/ai-tools/omp-sharing`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json", origin: BASE },
|
||||
body: JSON.stringify({ enabled: "yes" }),
|
||||
});
|
||||
assert.equal(put.status, 400);
|
||||
});
|
||||
});
|
||||
@@ -815,6 +815,8 @@ describe("pty-run", () => {
|
||||
);
|
||||
assert.deepEqual(paneArgv(call), [
|
||||
at("omp"),
|
||||
// A workspace at the account's home stays there (omp moves to a temp dir otherwise).
|
||||
"--allow-home",
|
||||
"--model",
|
||||
"opus",
|
||||
"--resume",
|
||||
@@ -823,7 +825,12 @@ describe("pty-run", () => {
|
||||
]);
|
||||
assert.throws(
|
||||
() =>
|
||||
pty.spawnRun({ laneId: 18, cwd: "/tmp/repo", tool: "omp", resumeSessionId: "--abcdefgh" }),
|
||||
pty.spawnRun({
|
||||
laneId: 18,
|
||||
cwd: "/tmp/repo",
|
||||
tool: "omp",
|
||||
resumeSessionId: "--abcdefgh",
|
||||
}),
|
||||
{ code: "EBADSESSION" }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -354,7 +354,19 @@ describe("POST /api/run/drop-image", () => {
|
||||
});
|
||||
assert.equal(status, 413);
|
||||
assert.equal(body.error.code, "ETOOBIG");
|
||||
assert.equal(body.error.max_bytes, 20 * 1024 * 1024); // the UI shows the server's limit
|
||||
// The UI shows the server's limit: a loopback browser's, here.
|
||||
assert.equal(body.error.max_bytes, 2 * 1024 ** 3);
|
||||
});
|
||||
|
||||
it("streams a loopback drop over the remote limit to disk", async () => {
|
||||
const buf = Buffer.alloc(21 * 1024 * 1024, 7);
|
||||
const r = await fetchJson("/api/run/drop-file?name=big.bin", {
|
||||
method: "POST",
|
||||
raw: { type: "application/octet-stream", buf },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(r.body.path, /[\\/]drops[\\/][0-9a-f]{16}-big\.bin$/);
|
||||
assert.equal(fs.statSync(r.body.path).size, buf.length);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -227,6 +227,9 @@ describe("broadcastAudience", () => {
|
||||
assert.equal(reaches("agent_updated", { session_id: "s3" }, m1), false);
|
||||
assert.equal(reaches("new_event", { session_id: "s2" }, admin.user), true);
|
||||
assert.equal(reaches("new_event", { session_id: "s2" }, undefined), true);
|
||||
// A transcript's growth notice goes where the session's events go.
|
||||
assert.equal(reaches("transcript_changed", { session_id: "s2" }, m2), true);
|
||||
assert.equal(reaches("transcript_changed", { session_id: "s2" }, m1), false);
|
||||
});
|
||||
|
||||
it("routes workspace messages by owner and keeps unknown types from members", () => {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @file server-mode-password-change.test.js
|
||||
* @description A password an admin GIVES (Users page create or reset) may be
|
||||
* anything non-empty, and the account must then pick its own before anything
|
||||
* else: its login reaches only the `/me` reads and `POST /api/me/password`
|
||||
* (EPWCHANGE), opens no WebSocket, and its own password must pass every rule
|
||||
* (EWEAKPASSWORD with the failed `rules`, never the current one). Also: the
|
||||
* last login survives a logout (`users.last_login_at`), the last enabled
|
||||
* admin can never be demoted or disabled (ELASTADMIN, the CLI's path
|
||||
* included) and old audit entries are pruned (`AGENTHUB_AUDIT_DAYS`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-password-change-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const BOSS_PASS = "Admin-pass-2026";
|
||||
|
||||
async function call(method, p, { cookie, body } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (res.status !== 200) return null;
|
||||
return { cookie: res.headers.get("set-cookie").split(";")[0], user: (await res.json()).user };
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
await auth.createUser("boss", BOSS_PASS, "admin");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
delete process.env.AGENTHUB_AUDIT_DAYS;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("a password an admin gave", () => {
|
||||
it("is accepted weak, then the user must pick a strong one of their own", async () => {
|
||||
const boss = (await login("boss", BOSS_PASS)).cookie;
|
||||
const made = await call("POST", "/api/users", {
|
||||
cookie: boss,
|
||||
body: { username: "neo", password: "123", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(made.status, 201);
|
||||
assert.equal(made.body.user.mustChangePassword, true);
|
||||
|
||||
const neo = await login("neo", "123");
|
||||
assert.equal(neo.user.mustChangePassword, true);
|
||||
// Only its own account and the password change, until it has one.
|
||||
assert.equal(
|
||||
(await call("GET", "/api/lanes", { cookie: neo.cookie })).body.error.code,
|
||||
"EPWCHANGE"
|
||||
);
|
||||
const patch = await call("PATCH", "/api/me/account", {
|
||||
cookie: neo.cookie,
|
||||
body: { displayName: "Neo" },
|
||||
});
|
||||
assert.equal(patch.body.error.code, "EPWCHANGE");
|
||||
assert.equal((await call("GET", "/api/me/account", { cookie: neo.cookie })).status, 200);
|
||||
const me = await call("GET", "/api/auth/me", { cookie: neo.cookie });
|
||||
assert.equal(me.body.user.mustChangePassword, true);
|
||||
|
||||
const weak = await call("POST", "/api/me/password", {
|
||||
cookie: neo.cookie,
|
||||
body: { currentPassword: "123", newPassword: "neo12345" },
|
||||
});
|
||||
assert.equal(weak.status, 400);
|
||||
assert.equal(weak.body.error.code, "EWEAKPASSWORD");
|
||||
assert.deepEqual(weak.body.error.rules, ["upper", "symbol", "username"]);
|
||||
|
||||
const ok = await call("POST", "/api/me/password", {
|
||||
cookie: neo.cookie,
|
||||
body: { currentPassword: "123", newPassword: "Green-Tea-42" },
|
||||
});
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal((await call("GET", "/api/lanes", { cookie: neo.cookie })).status, 200);
|
||||
assert.equal(
|
||||
(await call("GET", "/api/auth/me", { cookie: neo.cookie })).body.user.mustChangePassword,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("an admin's reset flags the account again; their own new password must pass every rule", async () => {
|
||||
const boss = (await login("boss", BOSS_PASS)).cookie;
|
||||
const neoId = auth.findUser("neo").id;
|
||||
const reset = await call("PATCH", `/api/users/${neoId}`, {
|
||||
cookie: boss,
|
||||
body: { password: "abc", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(reset.status, 200);
|
||||
assert.equal(reset.body.user.mustChangePassword, true);
|
||||
assert.equal((await login("neo", "abc")).user.mustChangePassword, true);
|
||||
|
||||
const bossId = auth.findUser("boss").id;
|
||||
const own = (password) =>
|
||||
call("PATCH", `/api/users/${bossId}`, {
|
||||
cookie: boss,
|
||||
body: { password, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal((await own("123")).body.error.code, "EWEAKPASSWORD");
|
||||
const same = await own(BOSS_PASS);
|
||||
assert.equal(same.body.error.code, "EWEAKPASSWORD");
|
||||
assert.deepEqual(same.body.error.rules, ["reused"]);
|
||||
const done = await own("Blue-Sky-77!");
|
||||
assert.equal(done.status, 200);
|
||||
assert.equal(done.body.user.mustChangePassword, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("account bookkeeping", () => {
|
||||
it("keeps the last login after the user signs out", async () => {
|
||||
const pass = "Blue-Sky-77!";
|
||||
const s = await login("boss", pass);
|
||||
await call("POST", "/api/auth/logout", { cookie: s.cookie });
|
||||
const id = auth.findUser("boss").id;
|
||||
db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").run(id);
|
||||
const stats = auth.loginStats(id);
|
||||
assert.equal(stats.logins, 0);
|
||||
assert.ok(stats.lastLoginAt, "the last login is still known");
|
||||
assert.equal(auth.allLoginStats().get(id).lastLoginAt, stats.lastLoginAt);
|
||||
});
|
||||
|
||||
it("never demotes, disables or deletes the last enabled admin", () => {
|
||||
for (const fn of [
|
||||
() => auth.setRole("boss", "developer"),
|
||||
() => auth.setDisabled("boss", true),
|
||||
() => auth.deleteUser("boss"),
|
||||
]) {
|
||||
assert.throws(fn, { code: "ELASTADMIN" });
|
||||
}
|
||||
assert.equal(auth.findUser("boss").role, "admin");
|
||||
assert.equal(auth.findUser("boss").disabled, 0);
|
||||
});
|
||||
|
||||
it("prunes audit entries older than AGENTHUB_AUDIT_DAYS", () => {
|
||||
const { pruneAudit } = require("../lib/audit");
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO audit_log (user_id, owner_id, method, path, at) VALUES (1, 1, 'GET', ?, ?)"
|
||||
);
|
||||
insert.run("/old", new Date(Date.now() - 40 * 864e5).toISOString());
|
||||
insert.run("/new", new Date().toISOString());
|
||||
process.env.AGENTHUB_AUDIT_DAYS = "0";
|
||||
assert.equal(pruneAudit({ force: true }), 0, "0 keeps everything");
|
||||
process.env.AGENTHUB_AUDIT_DAYS = "30";
|
||||
assert.ok(pruneAudit({ force: true }) >= 1);
|
||||
const paths = db
|
||||
.prepare("SELECT path FROM audit_log")
|
||||
.all()
|
||||
.map((r) => r.path);
|
||||
assert.ok(!paths.includes("/old"));
|
||||
assert.ok(paths.includes("/new"));
|
||||
});
|
||||
});
|
||||
@@ -230,19 +230,26 @@ describe("Settings → Account (/api/me)", () => {
|
||||
body: { currentPassword: "nope", newPassword: "leo-new-pass" },
|
||||
});
|
||||
assert.equal(wrong.body.error.code, "EREAUTH");
|
||||
const ok = await call("POST", "/api/me/password", {
|
||||
const weak = await call("POST", "/api/me/password", {
|
||||
cookie: leo,
|
||||
body: { currentPassword: "member-pass-2", newPassword: "leo-new-pass" },
|
||||
});
|
||||
assert.equal(weak.status, 400);
|
||||
assert.equal(weak.body.error.code, "EWEAKPASSWORD");
|
||||
assert.deepEqual(weak.body.error.rules, ["upper", "digit", "username"]);
|
||||
const ok = await call("POST", "/api/me/password", {
|
||||
cookie: leo,
|
||||
body: { currentPassword: "member-pass-2", newPassword: "New-Pass-2026" },
|
||||
});
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal((await call("GET", "/api/me/account", { cookie: leo })).status, 200);
|
||||
assert.equal((await call("GET", "/api/me/account", { cookie: other })).status, 401);
|
||||
assert.ok(await login("leo", "leo-new-pass"));
|
||||
assert.ok(await login("leo", "New-Pass-2026"));
|
||||
});
|
||||
|
||||
it("signs out other devices", async () => {
|
||||
const a = await login("leo", "leo-new-pass");
|
||||
const b = await login("leo", "leo-new-pass");
|
||||
const a = await login("leo", "New-Pass-2026");
|
||||
const b = await login("leo", "New-Pass-2026");
|
||||
const r = await call("POST", "/api/me/sign-out-others", { cookie: a });
|
||||
assert.ok(r.body.ended >= 1);
|
||||
assert.equal((await call("GET", "/api/me/account", { cookie: a })).status, 200);
|
||||
|
||||
@@ -437,6 +437,82 @@ describe("the top of a member's home", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the run form's `/` list", () => {
|
||||
it("reads the user skills, commands and plugins of the account the pane runs as", async () => {
|
||||
const skill = (claude, name) => {
|
||||
fs.mkdirSync(path.join(claude, "skills", name), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(claude, "skills", name, "SKILL.md"),
|
||||
`---\nname: ${name}\n---\nx\n`
|
||||
);
|
||||
};
|
||||
const hostClaude = process.env.CLAUDE_HOME;
|
||||
skill(hostClaude, "host-only");
|
||||
skill(path.join(MIA, ".claude"), "mia-skill");
|
||||
skill(path.join(BOB, ".claude"), "bob-skill");
|
||||
fs.mkdirSync(path.join(MIA, ".claude", "commands"), { recursive: true });
|
||||
fs.writeFileSync(path.join(MIA, ".claude", "commands", "mia-cmd.md"), "do it\n");
|
||||
fs.mkdirSync(path.join(MIA, ".claude", "plugins"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(MIA, ".claude", "plugins", "installed_plugins.json"),
|
||||
JSON.stringify({ plugins: { "mp@market": [{ scope: "user" }] } })
|
||||
);
|
||||
const names = (r) => r.body.items.map((i) => i.name);
|
||||
|
||||
const mia = await call("mia", "GET", "/api/cc-config/skills?scope=user");
|
||||
assert.deepEqual(names(mia), ["mia-skill"], JSON.stringify(mia.body));
|
||||
const cmds = await call("mia", "GET", "/api/cc-config/commands?scope=user");
|
||||
assert.deepEqual(names(cmds), ["mia-cmd"]);
|
||||
// Plugins stay staff-only; an admin's run form in a member folder lists that member's.
|
||||
const plugins = await call(
|
||||
"boss",
|
||||
"GET",
|
||||
`/api/cc-config/plugins?cwd=${encodeURIComponent(path.join(MIA, "proj"))}`
|
||||
);
|
||||
assert.deepEqual(
|
||||
plugins.body.plugins.map((p) => p.key),
|
||||
["mp@market"],
|
||||
JSON.stringify(plugins.body)
|
||||
);
|
||||
// A folder picks its own account's home.
|
||||
const bob = await call(
|
||||
"bob",
|
||||
"GET",
|
||||
`/api/cc-config/skills?scope=user&cwd=${encodeURIComponent(BOB)}`
|
||||
);
|
||||
assert.deepEqual(names(bob), ["bob-skill"], JSON.stringify(bob.body));
|
||||
// An admin's Config Explorer view, no folder named, stays the host's.
|
||||
const boss = await call("boss", "GET", "/api/cc-config/skills?scope=user");
|
||||
assert.deepEqual(names(boss), ["host-only"]);
|
||||
});
|
||||
|
||||
it("never lists another member's or the host's skills as someone's own", async () => {
|
||||
const names = (r) => r.body.items.map((i) => i.name);
|
||||
const bob = await call("bob", "GET", "/api/cc-config/skills?scope=user");
|
||||
assert.deepEqual(names(bob), ["bob-skill"]);
|
||||
const intoMia = await call(
|
||||
"bob",
|
||||
"GET",
|
||||
`/api/cc-config/skills?cwd=${encodeURIComponent(path.join(MIA, "proj"))}`
|
||||
);
|
||||
assert.equal(intoMia.status, 403);
|
||||
assert.equal(intoMia.body.error.code, "EFOLDER");
|
||||
// A developer with no Linux account of their own gets nothing, not the host's.
|
||||
await auth.createUser("eve", "eve-pass-123", "developer");
|
||||
const login = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "eve", password: "eve-pass-123" }),
|
||||
});
|
||||
cookies.eve = login.headers.get("set-cookie").split(";")[0];
|
||||
for (const kind of ["skills", "commands"]) {
|
||||
const r = await call("eve", "GET", `/api/cc-config/${kind}`);
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
assert.deepEqual(r.body.items, [], kind);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a disabled member's session", () => {
|
||||
it("is still read from their home, and does not stop the liveness reaper", () => {
|
||||
db.prepare("UPDATE users SET disabled = 1 WHERE username = 'dan'").run();
|
||||
|
||||
@@ -116,6 +116,10 @@ before(async () => {
|
||||
});
|
||||
cookies[n] = login.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
// A second admin, so demoting "boss" never leaves the install without one (ELASTADMIN).
|
||||
db.prepare(
|
||||
"INSERT INTO users (username, role, password_hash) VALUES ('boss2', 'admin', 'x')"
|
||||
).run();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
|
||||
@@ -13,7 +13,7 @@ id follows, as one more argv element — `pty-run.js` emits them in place of
|
||||
`resume` when a start carries `resumeSessionId`, and only for an id that starts
|
||||
with a letter or digit. A tool without it (and without a `launcher`) refuses a
|
||||
resume by id (`EUNSUPPORTED`) rather than start fresh under a "resume".
|
||||
Built-in: omp, Grok and Cursor (`--resume`), OpenCode (`--session`). A session id may carry `_` (OpenCode's `ses_…`).
|
||||
Built-in: omp, Grok and Cursor (`--resume`), OpenCode (`--session`). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_` (OpenCode's `ses_…`).
|
||||
|
||||
## Install / update (A2)
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"detect": [
|
||||
"omp"
|
||||
],
|
||||
"args": [
|
||||
"--allow-home"
|
||||
],
|
||||
"prompt": {
|
||||
"mode": "argv"
|
||||
},
|
||||
|
||||
@@ -1827,6 +1827,15 @@ try {
|
||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN pane_token_hash TEXT").run();
|
||||
}
|
||||
|
||||
// A realtime chat run's bridge secret (`AGENTHUB_CHAT_TOKEN`), SHA-256: only the
|
||||
// pane's own bridge may stream that run's events (server/routes/chat.js).
|
||||
// Never listed.
|
||||
try {
|
||||
db.prepare("SELECT chat_token_hash FROM dashboard_runs LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN chat_token_hash TEXT").run();
|
||||
}
|
||||
|
||||
// Server mode: whose browser a push subscription is (`users.id`); NULL in
|
||||
// local mode. A user's notifications go only to their own subscriptions.
|
||||
try {
|
||||
@@ -1933,6 +1942,19 @@ for (const col of ["display_name", "email", "avatar_at"]) {
|
||||
db.prepare(`ALTER TABLE users ADD COLUMN ${col} TEXT`).run();
|
||||
}
|
||||
}
|
||||
// When the account last logged in (kept past its logins' expiry, unlike
|
||||
// `auth_sessions`), and whether it must pick its own password before anything
|
||||
// else (set when an admin creates it or resets its password — server/lib/auth.js).
|
||||
for (const [col, type] of [
|
||||
["last_login_at", "TEXT"],
|
||||
["must_change_password", "INTEGER NOT NULL DEFAULT 0"],
|
||||
]) {
|
||||
try {
|
||||
db.prepare(`SELECT ${col} FROM users LIMIT 1`).get();
|
||||
} catch {
|
||||
db.prepare(`ALTER TABLE users ADD COLUMN ${col} ${type}`).run();
|
||||
}
|
||||
}
|
||||
// An admin's pin of one server-mode user to one pool account per tool
|
||||
// (server/lib/account-pins.js): that user's new sessions skip the balancer.
|
||||
// The account is named, not referenced — the pool lives in files.
|
||||
|
||||
+43
-5
@@ -1032,7 +1032,9 @@ function startWorkflowPoll(broadcast) {
|
||||
* sessions show up immediately rather than on the next poll. Events for
|
||||
* files already in `mtimeCache` (active transcripts being appended to) are
|
||||
* ignored, so a busy session never thrashes the importer — the poll picks
|
||||
* up its growth. Recursive watching is used only on macOS/Windows (native,
|
||||
* up its growth; such an append only broadcasts `transcript_changed`
|
||||
* `{session_id}` (at most every 250 ms per session), so an open chat reads
|
||||
* the new lines at once. Recursive watching is used only on macOS/Windows (native,
|
||||
* stable); on Linux, where Node's userland recursive watcher trips on the
|
||||
* high-churn projects tree (see lib/cc-watcher.js), we watch the root plus
|
||||
* each immediate child folder non-recursively instead.
|
||||
@@ -1148,11 +1150,37 @@ function startSessionSync(broadcast) {
|
||||
}, DEBOUNCE_MS);
|
||||
if (debounce.unref) debounce.unref();
|
||||
}
|
||||
// Only a path we don't already track is interesting (a new session file or a
|
||||
// new project folder). Appends to a known active transcript are left to the
|
||||
// poll, so the watcher never re-parses a busy session every write.
|
||||
// An append to a session's own transcript: a chat showing that session reads
|
||||
// it now instead of at its next poll. One `transcript_changed` per session
|
||||
// per PUSH_MS — a notice only, nothing is parsed here.
|
||||
const PUSH_MS = 250;
|
||||
const pushing = new Map(); // session id → pending timer
|
||||
function pushTranscript(fullPath) {
|
||||
const m =
|
||||
/(?:^|[\\/])([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i.exec(
|
||||
fullPath
|
||||
);
|
||||
if (!m || pushing.has(m[1])) return;
|
||||
const sessionId = m[1];
|
||||
const timer = setTimeout(() => {
|
||||
pushing.delete(sessionId);
|
||||
try {
|
||||
broadcast("transcript_changed", { session_id: sessionId });
|
||||
} catch {
|
||||
/* best-effort — the chat's poll still catches up */
|
||||
}
|
||||
}, PUSH_MS);
|
||||
if (timer.unref) timer.unref();
|
||||
pushing.set(sessionId, timer);
|
||||
}
|
||||
|
||||
// Only a path we don't already track is interesting to the importer (a new
|
||||
// session file or a new project folder). Appends to a known active
|
||||
// transcript are left to the poll, so the watcher never re-parses a busy
|
||||
// session every write — it only tells the chats (`pushTranscript`).
|
||||
function onFsEvent(fullPath) {
|
||||
if (fullPath && mtimeCache.has(fullPath)) return;
|
||||
if (fullPath && mtimeCache.has(fullPath)) return void pushTranscript(fullPath);
|
||||
if (fullPath) pushTranscript(fullPath);
|
||||
scheduleSweep();
|
||||
}
|
||||
|
||||
@@ -1269,6 +1297,10 @@ if (require.main === module) {
|
||||
startBackgroundServices();
|
||||
// The status-area icon (server/lib/tray.js) — production servers only.
|
||||
require("./lib/tray").startTrayWatch();
|
||||
// Oh My Pi login sharing's broker + gateway, when an admin turned it on (S3).
|
||||
require("./lib/omp-share")
|
||||
.start()
|
||||
.catch((err) => console.warn("omp share failed to start:", err.message));
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(listenFailure(err, PORT));
|
||||
@@ -1284,6 +1316,12 @@ if (require.main === module) {
|
||||
process.exit(1);
|
||||
}
|
||||
shutdownInProgress = true;
|
||||
// Its omp daemons are this server's children; KillMode=process would keep them.
|
||||
try {
|
||||
require("./lib/omp-share").stop();
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
console.log(`\n${signal} received — shutting down gracefully… (hit Ctrl+C again to force)`);
|
||||
|
||||
// Drop realtime clients first — open WS sockets otherwise hold the HTTP
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* 5 cache read.
|
||||
* The session's name and start come from its transcript
|
||||
* (`brain/<id>/.system_generated/logs/transcript_full.jsonl`, first
|
||||
* `USER_INPUT`). A file is re-read whole when its mtime (or its non-empty
|
||||
* `USER_INPUT`), and the same file is the conversation view's
|
||||
* (`transcriptPath`, read by `GET /api/sessions/:id/transcript`). A file is re-read whole when its mtime (or its non-empty
|
||||
* `-wal`'s) changes — they are small — and the mtime per file lives in
|
||||
* `antigravity_conversations`, so a session the user deleted stays deleted
|
||||
* until agy writes to it again. Opening read-only still lets SQLite leave an
|
||||
@@ -157,6 +158,21 @@ const transcriptFiles = (id) =>
|
||||
return ["transcript_full.jsonl", "transcript.jsonl"].map((name) => path.join(dir, name));
|
||||
});
|
||||
|
||||
/** The transcript of conversation `id` (this account's or a member's) the
|
||||
* conversation view reads, or null. Only a UUID names one. */
|
||||
function transcriptPath(id) {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(id || "")))
|
||||
return null;
|
||||
for (const file of transcriptFiles(id)) {
|
||||
try {
|
||||
if (fs.statSync(file).isFile()) return file;
|
||||
} catch {
|
||||
/* try the next name */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The transcript's last write, 0 when it has none. */
|
||||
function transcriptMtime(id) {
|
||||
for (const file of transcriptFiles(id)) {
|
||||
@@ -531,4 +547,5 @@ module.exports = {
|
||||
readConversation,
|
||||
ingestAntigravityUsage,
|
||||
startAntigravityUsagePoll,
|
||||
transcriptPath,
|
||||
};
|
||||
|
||||
+132
-39
@@ -33,11 +33,14 @@
|
||||
* `~/.claude/agenthub-replaced-skills/`, never deleted. A plugin is
|
||||
* installed without `-y`, so one whose marketplace declares an install
|
||||
* command fails rather than runs unseen. An admin can turn installs off
|
||||
* for everyone (`setInstallsEnabled`). Share, unshare and install are
|
||||
* written to `audit_log`.
|
||||
* for everyone (`setInstallsEnabled`). Uninstall (`uninstall`) takes it out
|
||||
* of the same accounts again — a skill moved aside the same way, a plugin
|
||||
* `claude plugin uninstall --keep-data` — and is allowed with installs off.
|
||||
* Share, unshare, install and uninstall are written to `audit_log`.
|
||||
* - Editing a skill's name, description or instructions (`editSkill`) writes
|
||||
* a NEW snapshot and swaps the row to it; the old snapshot is dropped, and
|
||||
* copies already installed stay as they were (Reinstall picks it up).
|
||||
* copies already installed stay as they were (uninstall and install again
|
||||
* to take the new one).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -147,7 +150,8 @@ function view(row, req, owners = null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows this request may see. `scope`: `mine` (own rows; every row in
|
||||
* The rows this request may see. `scope`: `mine` (own library rows plus the
|
||||
* shared skills/plugins of others the caller has installed; every row in
|
||||
* local mode), `hub` (shared rows, server mode), anything else = all visible.
|
||||
* Each row also carries `installed` (`installedCheck`).
|
||||
*/
|
||||
@@ -156,8 +160,12 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
|
||||
const where = [v.where];
|
||||
const params = [...v.params];
|
||||
if (scope === "mine" && req?.user) {
|
||||
where.push("a.owner_id = ? AND a.in_library = 1");
|
||||
params.push(req.user.id);
|
||||
// Their own library, plus what others shared that they installed (kept
|
||||
// below only when `installed` — a disk read, so it cannot be SQL).
|
||||
where.push(
|
||||
"((a.owner_id = ? AND a.in_library = 1) OR (a.shared = 1 AND a.owner_id IS NOT ? AND a.kind IN ('skill', 'plugin')))"
|
||||
);
|
||||
params.push(req.user.id, req.user.id);
|
||||
} else if (scope === "hub") {
|
||||
where.push("a.shared = 1");
|
||||
}
|
||||
@@ -191,7 +199,8 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
|
||||
).all(...params);
|
||||
const owners = new Map();
|
||||
const installed = installedCheck(req);
|
||||
return rows.map((r) => ({ ...view(r, req, owners), installed: installed(r) }));
|
||||
const out = rows.map((r) => ({ ...view(r, req, owners), installed: installed(r) }));
|
||||
return scope === "mine" && req?.user ? out.filter((a) => a.mine || a.installed) : out;
|
||||
}
|
||||
|
||||
// ── Small helpers ─────────────────────────────────────────────────────────
|
||||
@@ -282,12 +291,25 @@ function addPage(req, { url, title, description, laneId, sessionId }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who a hook's page belongs to when no workspace owns its session (a member's
|
||||
* `claude` started from their own terminal): the S3 member the hook's
|
||||
* connection came from (`hook-auth.js` `hookPeer`, by uid — never the body).
|
||||
* The host's account is shared by every admin, so it names nobody.
|
||||
*/
|
||||
function peerOwner(peer) {
|
||||
if (peer?.kind !== "member" || !peer.name) return null;
|
||||
const row = sql("SELECT id FROM users WHERE username = ? AND disabled = 0").get(peer.name);
|
||||
return row ? row.id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Artifact` tool's PostToolUse hook: a publish answers with the page's
|
||||
* claude.ai URL somewhere in its response. Recorded once per (owner, url),
|
||||
* owned by the session's workspace owner. Returns the row or null.
|
||||
* owned by the session's workspace owner, else by the member who sent the
|
||||
* hook (`peerOwner`). Returns the row or null.
|
||||
*/
|
||||
function captureFromHook(sessionId, data) {
|
||||
function captureFromHook(sessionId, data, peer = null) {
|
||||
const input = data?.tool_input || {};
|
||||
if (input.action && input.action !== "publish") return null;
|
||||
const text = JSON.stringify(data?.tool_response ?? "");
|
||||
@@ -295,7 +317,8 @@ function captureFromHook(sessionId, data) {
|
||||
if (!m) return null;
|
||||
const url = m[0].replace(/[\\"]+$/, "");
|
||||
if (!PAGE_URL_RE.test(url)) return null;
|
||||
const ownerId = sessionOwner(sessionId);
|
||||
const laneId = sessionLane(sessionId);
|
||||
const ownerId = laneId != null ? sessionOwner(sessionId) : peerOwner(peer);
|
||||
const same = sql("SELECT * FROM artifacts WHERE kind = 'page' AND url = ? AND owner_id IS ?").get(
|
||||
url,
|
||||
ownerId
|
||||
@@ -307,7 +330,7 @@ function captureFromHook(sessionId, data) {
|
||||
title: cleanTitle(input.title, file ? path.basename(file) : "Artifact"),
|
||||
description: cleanDescription(input.description),
|
||||
owner_id: ownerId,
|
||||
lane_id: sessionLane(sessionId),
|
||||
lane_id: laneId,
|
||||
session_id: String(sessionId),
|
||||
source: file ? file.slice(0, 1000) : null,
|
||||
url,
|
||||
@@ -1409,6 +1432,31 @@ function installedCheck(req) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Move installed skill folders (`{acct, claudeHome, target}`) aside to
|
||||
* `~/.claude/agenthub-replaced-skills/<name>[-agents][-uninstalled]-<stamp>`,
|
||||
* as their account — a replace or an uninstall never deletes a user's copy.
|
||||
*/
|
||||
async function moveAside(taken, row, label = "") {
|
||||
const runAs = require("./run-as");
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
for (const t of taken) {
|
||||
const tag = t.target.startsWith(t.claudeHome + path.sep) ? "" : "-agents";
|
||||
const aside = path.join(
|
||||
t.claudeHome,
|
||||
"agenthub-replaced-skills",
|
||||
`${row.source}${tag}${label}-${stamp}`
|
||||
);
|
||||
if (t.acct) {
|
||||
runAs.mkdirAs(t.acct, path.dirname(aside));
|
||||
await runAs.writeAsAsync(t.acct, t.target, ["move", aside]);
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(aside), { recursive: true });
|
||||
fs.renameSync(t.target, aside);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a skill snapshot as `<name>` into every skills folder of every
|
||||
* account the viewer's sessions run as (`installAccounts` × `skillDirsOf`).
|
||||
@@ -1428,22 +1476,7 @@ async function installSkill(req, row, { replace = false } = {}) {
|
||||
"replacing needs the updated helper: run `agenthub server install-system` once"
|
||||
);
|
||||
}
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
for (const t of taken) {
|
||||
const tag = t.target.startsWith(t.claudeHome + path.sep) ? "" : "-agents";
|
||||
const aside = path.join(
|
||||
t.claudeHome,
|
||||
"agenthub-replaced-skills",
|
||||
`${row.source}${tag}-${stamp}`
|
||||
);
|
||||
if (t.acct) {
|
||||
runAs.mkdirAs(t.acct, path.dirname(aside));
|
||||
await runAs.writeAsAsync(t.acct, t.target, ["move", aside]);
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(aside), { recursive: true });
|
||||
fs.renameSync(t.target, aside);
|
||||
}
|
||||
}
|
||||
await moveAside(taken, row);
|
||||
const files = row.files ? JSON.parse(row.files) : [];
|
||||
const contents = files.map((f) => ({
|
||||
rel: f.path.split("/"),
|
||||
@@ -1470,14 +1503,9 @@ function __setExecImpl(fn) {
|
||||
execImpl = fn;
|
||||
}
|
||||
|
||||
/** `claude plugin marketplace add` + `claude plugin install --scope user`, as the viewer. */
|
||||
async function installPlugin(req, row) {
|
||||
/** `args => Promise`: runs `claude <args>` as the viewer's own account. */
|
||||
function claudeAsViewer(req) {
|
||||
const { acct } = ownAccount(req);
|
||||
const steps = [
|
||||
["plugin", "marketplace", "add", validMarket(row.market)],
|
||||
["plugin", "install", "--scope", "user", row.source],
|
||||
];
|
||||
if (!PLUGIN_RE.test(row.source)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
|
||||
let run;
|
||||
if (acct) {
|
||||
const runAs = require("./run-as");
|
||||
@@ -1493,11 +1521,23 @@ async function installPlugin(req, row) {
|
||||
if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome();
|
||||
run = (args) => execImpl("claude", args, { timeout: 300_000, encoding: "utf8", env });
|
||||
}
|
||||
const lastLine = (e) =>
|
||||
String(e?.stderr || e?.stdout || e?.message || "")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.pop();
|
||||
return run;
|
||||
}
|
||||
|
||||
const lastLine = (e) =>
|
||||
String(e?.stderr || e?.stdout || e?.message || "")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.pop();
|
||||
|
||||
/** `claude plugin marketplace add` + `claude plugin install --scope user`, as the viewer. */
|
||||
async function installPlugin(req, row) {
|
||||
const steps = [
|
||||
["plugin", "marketplace", "add", validMarket(row.market)],
|
||||
["plugin", "install", "--scope", "user", row.source],
|
||||
];
|
||||
if (!PLUGIN_RE.test(row.source)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
|
||||
const run = claudeAsViewer(req);
|
||||
try {
|
||||
await run(steps[0]);
|
||||
} catch (e) {
|
||||
@@ -1527,6 +1567,58 @@ async function install(req, id, opts = {}) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take an installed skill or plugin out of the viewer's own accounts again
|
||||
* (`installAccounts`). A skill's folders are moved aside like a replace
|
||||
* (`moveAside`, `-uninstalled`), never deleted; a plugin is `claude plugin
|
||||
* uninstall --scope user --keep-data`, as the viewer, so its data dir stays.
|
||||
* Allowed while installs are off — it only removes. `ENOTINSTALLED` when
|
||||
* there is nothing of it to remove.
|
||||
*/
|
||||
async function uninstall(req, id) {
|
||||
const row = load(req, id);
|
||||
if (row.kind !== "skill" && row.kind !== "plugin") {
|
||||
throw fail("EBADKIND", "only a skill or a plugin is uninstalled");
|
||||
}
|
||||
let result;
|
||||
if (row.kind === "skill") {
|
||||
const runAs = require("./run-as");
|
||||
const taken = installAccounts(req)
|
||||
.flatMap((a) => skillDirsOf(a).map((dir) => ({ ...a, target: path.join(dir, row.source) })))
|
||||
.filter((t) => exists(t.target));
|
||||
if (!taken.length) throw fail("ENOTINSTALLED", `${row.source} is not installed`);
|
||||
if (taken.some((t) => t.acct) && !runAs.writeAsCanMove()) {
|
||||
throw fail(
|
||||
"EUPGRADE",
|
||||
"uninstalling needs the updated helper: run `agenthub server install-system` once"
|
||||
);
|
||||
}
|
||||
await moveAside(taken, row, "-uninstalled");
|
||||
result = { uninstalled: row.source, paths: taken.map((t) => t.target) };
|
||||
} else {
|
||||
if (!PLUGIN_RE.test(row.source)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
|
||||
try {
|
||||
await claudeAsViewer(req)([
|
||||
"plugin",
|
||||
"uninstall",
|
||||
"--scope",
|
||||
"user",
|
||||
"--keep-data",
|
||||
row.source,
|
||||
]);
|
||||
} catch (e) {
|
||||
const msg = lastLine(e);
|
||||
if (/not (found|installed)/i.test(msg)) {
|
||||
throw fail("ENOTINSTALLED", `${row.source} is not installed`);
|
||||
}
|
||||
throw fail("EPLUGIN", `uninstall: ${msg}`);
|
||||
}
|
||||
result = { uninstalled: row.source, paths: [] };
|
||||
}
|
||||
if (req?.user && row.owner_id !== req.user.id) audit(req, row, "uninstalled");
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who installed artifact `id`, newest first: `{items: [{user, mine, at,
|
||||
* replaced, paths?}], unrecorded}`. The owner, staff and local mode see every
|
||||
@@ -1593,6 +1685,7 @@ module.exports = {
|
||||
remove,
|
||||
snapshotFile,
|
||||
install,
|
||||
uninstall,
|
||||
installsEnabled,
|
||||
setInstallsEnabled,
|
||||
__setExecImpl,
|
||||
|
||||
+40
-10
@@ -8,11 +8,47 @@
|
||||
* for a member (who can only read their own). An admin's account change on
|
||||
* the Users page is written here too (`recordAccountChange`), and so is an
|
||||
* admin's look into another user's home (`recordHomeRead`, File Explorer).
|
||||
* Entries are kept `AGENTHUB_AUDIT_DAYS` days (default 180; `0` keeps them
|
||||
* all): older ones are deleted as new ones are written, at most once an hour.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const sql = (q) => require("../db").db.prepare(q);
|
||||
|
||||
const DEFAULT_KEEP_DAYS = 180;
|
||||
const PRUNE_EVERY_MS = 60 * 60 * 1000;
|
||||
let lastPrune = 0;
|
||||
|
||||
/** Days of audit history kept; 0 = forever. */
|
||||
function keepDays() {
|
||||
const v = process.env.AGENTHUB_AUDIT_DAYS;
|
||||
if (v === undefined || v === "") return DEFAULT_KEEP_DAYS;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : DEFAULT_KEEP_DAYS;
|
||||
}
|
||||
|
||||
/** Deletes entries older than `keepDays()`; returns how many. `force` skips the hourly gate. */
|
||||
function pruneAudit({ force = false } = {}) {
|
||||
const days = keepDays();
|
||||
if (!days || (!force && Date.now() - lastPrune < PRUNE_EVERY_MS)) return 0;
|
||||
lastPrune = Date.now();
|
||||
const cutoff = new Date(Date.now() - days * 864e5).toISOString();
|
||||
try {
|
||||
return sql("DELETE FROM audit_log WHERE at < ?").run(cutoff).changes;
|
||||
} catch (err) {
|
||||
console.warn("[audit] could not prune:", err.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes one entry, then prunes when it is time. */
|
||||
function insertEntry(userId, ownerId, sessionId, method, path) {
|
||||
sql(
|
||||
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, ?, ?, ?)"
|
||||
).run(userId, ownerId, sessionId, method, path);
|
||||
pruneAudit();
|
||||
}
|
||||
|
||||
// Paths that name one session's content.
|
||||
const SESSION_PATHS = [
|
||||
/^\/sessions\/(?!facets\/?$)([^/]+)/i,
|
||||
@@ -35,9 +71,7 @@ function auditGuard(req, res, next) {
|
||||
if (owner === u.id) continue;
|
||||
const ownerRole = sql("SELECT role FROM users WHERE id = ?").get(owner)?.role;
|
||||
if (ownerRole !== "developer") continue;
|
||||
sql(
|
||||
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, ?, ?, ?)"
|
||||
).run(u.id, owner, id, req.method, req.originalUrl.slice(0, 500));
|
||||
insertEntry(u.id, owner, id, req.method, req.originalUrl.slice(0, 500));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[audit] could not record:", err.message);
|
||||
@@ -64,20 +98,16 @@ function listAudit(limit = 100) {
|
||||
/** An admin's account change from the Users page: who, on whom, what (never
|
||||
* a password). `owner_id` is the account changed; `path` carries the action. */
|
||||
function recordAccountChange(actorId, targetId, method, action) {
|
||||
sql(
|
||||
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, NULL, ?, ?)"
|
||||
).run(actorId, targetId, method, String(action).slice(0, 500));
|
||||
insertEntry(actorId, targetId, null, method, String(action).slice(0, 500));
|
||||
}
|
||||
|
||||
/** An admin opening another user's home in the File Explorer (read-only). */
|
||||
function recordHomeRead(actorId, ownerId, method, what) {
|
||||
try {
|
||||
sql(
|
||||
"INSERT INTO audit_log (user_id, owner_id, session_id, method, path) VALUES (?, ?, NULL, ?, ?)"
|
||||
).run(actorId, ownerId, method, String(what).slice(0, 500));
|
||||
insertEntry(actorId, ownerId, null, method, String(what).slice(0, 500));
|
||||
} catch (err) {
|
||||
console.warn("[audit] could not record:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { auditGuard, listAudit, recordAccountChange, recordHomeRead };
|
||||
module.exports = { auditGuard, listAudit, recordAccountChange, recordHomeRead, pruneAudit };
|
||||
|
||||
+127
-17
@@ -22,6 +22,15 @@
|
||||
* (`validateProfile`), which its owner edits in Settings → Account without
|
||||
* that step-up; changing their OWN password there needs the old one and
|
||||
* keeps the login it was changed from (`setPassword`'s `keepToken`).
|
||||
* - Two password bars: an admin may GIVE any non-empty password (a create or a
|
||||
* reset — `validatePassword`), and such an account is flagged
|
||||
* `must_change_password`; a password its OWNER picks must pass every rule of
|
||||
* `passwordProblems` (`validateOwnPassword`). While the flag is set, a
|
||||
* cookie login reaches only `/auth/*`, the `/me` reads and
|
||||
* `POST /me/password` (EPWCHANGE) and opens no WebSocket; a pane token is
|
||||
* not held back — its agents were started before the reset.
|
||||
* - Neither the dashboard nor the CLI can leave the install with no enabled
|
||||
* admin (`assertKeepsAdmin`, ELASTADMIN).
|
||||
*
|
||||
* This is login, not isolation: every member still sees every workspace and
|
||||
* session, and every pane runs as the one host account (phases S2/S3 of the
|
||||
@@ -134,6 +143,39 @@ function validatePassword(password) {
|
||||
if (password.length > 1024) throw err("EBADPASSWORD", "password too long");
|
||||
}
|
||||
|
||||
/** Shortest password an account may pick for itself. */
|
||||
const PASSWORD_MIN_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* The rules a password picked by its OWNER fails, as ids (empty = it passes):
|
||||
* `length`, `lower`, `upper`, `digit`, `symbol` and `username` (it contains
|
||||
* the username). The client's checklist (client/src/lib/passwordRules.ts)
|
||||
* mirrors these; this is the one that decides.
|
||||
*/
|
||||
function passwordProblems(password, username) {
|
||||
const p = String(password ?? "");
|
||||
const out = [];
|
||||
if ([...p].length < PASSWORD_MIN_LENGTH) out.push("length");
|
||||
if (!/\p{Ll}/u.test(p)) out.push("lower");
|
||||
if (!/\p{Lu}/u.test(p)) out.push("upper");
|
||||
if (!/\p{N}/u.test(p)) out.push("digit");
|
||||
if (!/[^\p{L}\p{N}]/u.test(p)) out.push("symbol");
|
||||
const name = String(username || "").toLowerCase();
|
||||
if (name && p.toLowerCase().includes(name)) out.push("username");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A password the account picks for itself: every rule (EWEAKPASSWORD). */
|
||||
function validateOwnPassword(password, username) {
|
||||
validatePassword(password);
|
||||
const failed = passwordProblems(password, username);
|
||||
if (failed.length) {
|
||||
throw Object.assign(err("EWEAKPASSWORD", `password too weak: ${failed.join(", ")}`), {
|
||||
rules: failed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Users (the CLI's `agenthub users …` and `agenthub server setup`) ────────
|
||||
|
||||
const publicUser = (u) =>
|
||||
@@ -147,6 +189,8 @@ const publicUser = (u) =>
|
||||
// When the avatar was last set (null = none): the client's cache-buster.
|
||||
avatarAt: u.avatar_at ?? null,
|
||||
createdAt: u.created_at ?? null,
|
||||
// An admin gave this password: the account must pick its own first.
|
||||
mustChangePassword: Boolean(u.must_change_password),
|
||||
};
|
||||
|
||||
const MAX_DISPLAY_NAME = 100;
|
||||
@@ -199,7 +243,8 @@ function findUser(username) {
|
||||
return sql("SELECT * FROM users WHERE username = ?").get(String(username || ""));
|
||||
}
|
||||
|
||||
async function createUser(username, password, role = "developer") {
|
||||
/** `mustChange`: an admin gave the password — the user picks their own at first login. */
|
||||
async function createUser(username, password, role = "developer", { mustChange = false } = {}) {
|
||||
const name = validateUsername(username);
|
||||
const linux = require("./linux-accounts");
|
||||
linux.assertLinuxName(name);
|
||||
@@ -210,8 +255,8 @@ async function createUser(username, password, role = "developer") {
|
||||
// S3: the Linux account first — a user who cannot get one is not created.
|
||||
linux.provision("create", name);
|
||||
const { lastInsertRowid } = sql(
|
||||
"INSERT INTO users (username, role, password_hash) VALUES (?, ?, ?)"
|
||||
).run(name, role, hash);
|
||||
"INSERT INTO users (username, role, password_hash, must_change_password) VALUES (?, ?, ?, ?)"
|
||||
).run(name, role, hash, mustChange ? 1 : 0);
|
||||
// S3, opt-in: the admin's plugin baseline, installed AS the new member in
|
||||
// the background (each marketplace is a clone) — the user exists already.
|
||||
require("./member-claude")
|
||||
@@ -227,14 +272,29 @@ function requireUser(username) {
|
||||
}
|
||||
|
||||
/** New password; every login of that user ends — but `keepToken`'s, when
|
||||
* the user changed their own password from that login. */
|
||||
async function setPassword(username, password, { keepToken } = {}) {
|
||||
* the user changed their own password from that login. `mustChange`: an admin
|
||||
* gave it, so the user must pick their own next; without it the flag clears. */
|
||||
async function setPassword(username, password, { keepToken, mustChange = false } = {}) {
|
||||
const u = requireUser(username);
|
||||
validatePassword(password);
|
||||
sql("UPDATE users SET password_hash = ? WHERE id = ?").run(await hashPassword(password), u.id);
|
||||
sql("UPDATE users SET password_hash = ?, must_change_password = ? WHERE id = ?").run(
|
||||
await hashPassword(password),
|
||||
mustChange ? 1 : 0,
|
||||
u.id
|
||||
);
|
||||
endSessions(u.id, keepToken);
|
||||
}
|
||||
|
||||
/** EWEAKPASSWORD (`reused`) when `password` is the account's current one. */
|
||||
async function assertNotCurrent(userId, password) {
|
||||
const row = sql("SELECT password_hash FROM users WHERE id = ?").get(userId);
|
||||
if (row && (await verifyPassword(String(password ?? ""), row.password_hash))) {
|
||||
throw Object.assign(err("EWEAKPASSWORD", "the new password is the current one"), {
|
||||
rules: ["reused"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** End every login of `userId` but `exceptToken`'s; returns how many ended. */
|
||||
function endSessions(userId, exceptToken) {
|
||||
if (exceptToken) {
|
||||
@@ -246,18 +306,52 @@ function endSessions(userId, exceptToken) {
|
||||
return sql("DELETE FROM auth_sessions WHERE user_id = ?").run(userId).changes;
|
||||
}
|
||||
|
||||
/** `{ logins, lastLoginAt }`: the user's unexpired logins and the newest one's start. */
|
||||
/** `{ logins, lastLoginAt }`: the user's unexpired logins and their last login
|
||||
* ever (`users.last_login_at`; the newest login's start for one made before it). */
|
||||
function loginStats(userId) {
|
||||
const r = sql(
|
||||
`SELECT COUNT(*) AS n, MAX(created_at) AS last FROM auth_sessions
|
||||
WHERE user_id = ? AND expires_at > ?`
|
||||
).get(userId, new Date().toISOString());
|
||||
return { logins: r.n, lastLoginAt: r.last || null };
|
||||
const at = sql("SELECT last_login_at FROM users WHERE id = ?").get(userId)?.last_login_at;
|
||||
return { logins: r.n, lastLoginAt: at || r.last || null };
|
||||
}
|
||||
|
||||
/** `loginStats` for every account in two queries: Map(user id → stats). */
|
||||
function allLoginStats() {
|
||||
const now = new Date().toISOString();
|
||||
const live = new Map(
|
||||
sql(
|
||||
`SELECT user_id, COUNT(*) AS n, MAX(created_at) AS last FROM auth_sessions
|
||||
WHERE expires_at > ? GROUP BY user_id`
|
||||
)
|
||||
.all(now)
|
||||
.map((r) => [r.user_id, r])
|
||||
);
|
||||
const out = new Map();
|
||||
for (const u of sql("SELECT id, last_login_at FROM users").all()) {
|
||||
const r = live.get(u.id);
|
||||
out.set(u.id, { logins: r?.n || 0, lastLoginAt: u.last_login_at || r?.last || null });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* ELASTADMIN when `u` is the only enabled admin and the change would leave it
|
||||
* no longer one — the CLI included, which has no "yourself" to refuse.
|
||||
*/
|
||||
function assertKeepsAdmin(u, what) {
|
||||
if (u.role !== "admin" || u.disabled) return;
|
||||
const others = sql(
|
||||
"SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND disabled = 0 AND id != ?"
|
||||
).get(u.id).n;
|
||||
if (!others) throw err("ELASTADMIN", `the last admin cannot be ${what}`);
|
||||
}
|
||||
|
||||
function setRole(username, role) {
|
||||
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
|
||||
const u = requireUser(username);
|
||||
if (role !== "admin") assertKeepsAdmin(u, "demoted");
|
||||
sql("UPDATE users SET role = ? WHERE id = ?").run(role, u.id);
|
||||
if (role !== "admin") stopHostPanes(u.id);
|
||||
}
|
||||
@@ -287,6 +381,7 @@ function stopHostPanes(userId) {
|
||||
* `deleteUser` below is the one that removes an account for good. */
|
||||
function setDisabled(username, disabled) {
|
||||
const u = requireUser(username);
|
||||
if (disabled) assertKeepsAdmin(u, "disabled");
|
||||
const linux = require("./linux-accounts");
|
||||
// S3: lock (and end the processes of) the Linux account too — when there is
|
||||
// one: a user made before S3, or whose name fits no Linux login, has none,
|
||||
@@ -316,12 +411,7 @@ function setDisabled(username, disabled) {
|
||||
*/
|
||||
function deleteUser(username) {
|
||||
const u = requireUser(username);
|
||||
if (u.role === "admin") {
|
||||
const others = sql(
|
||||
"SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND disabled = 0 AND id != ?"
|
||||
).get(u.id).n;
|
||||
if (!others) throw err("ELASTADMIN", "the last admin cannot be deleted");
|
||||
}
|
||||
assertKeepsAdmin(u, "deleted");
|
||||
const lanes = sql("SELECT id, cwd FROM lanes WHERE owner_id = ?").all(u.id);
|
||||
sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
|
||||
const pty = require("./pty-run");
|
||||
@@ -514,6 +604,7 @@ async function login(req, username, password) {
|
||||
if (!u || !ok || u.disabled) throw err("EBADLOGIN", "wrong username or password");
|
||||
failures.delete(ipKey);
|
||||
failures.delete(userKey);
|
||||
sql("UPDATE users SET last_login_at = ? WHERE id = ?").run(new Date().toISOString(), u.id);
|
||||
return { user: publicUser(u), token: startSession(u.id) };
|
||||
}
|
||||
|
||||
@@ -598,6 +689,12 @@ const underPrefix = (p, x) => {
|
||||
|
||||
const deny = (res, status, code, message) => res.status(status).json({ error: { code, message } });
|
||||
|
||||
/** What a login flagged `must_change_password` may still call. */
|
||||
function mayBeforePasswordChange(method, lp) {
|
||||
if (method === "GET" || method === "HEAD") return lp === "/me" || lp.startsWith("/me/");
|
||||
return method === "POST" && lp === "/me/password";
|
||||
}
|
||||
|
||||
/** Express middleware for `/api`; a no-op in local mode. */
|
||||
function authGuard(req, res, next) {
|
||||
if (!isServerMode()) return next();
|
||||
@@ -610,9 +707,15 @@ function authGuard(req, res, next) {
|
||||
? next()
|
||||
: deny(res, 403, "ELOCALONLY", "hooks are accepted from this machine only");
|
||||
}
|
||||
const user = userFromRequest(req) || userFromPaneToken(req);
|
||||
const cookieUser = userFromRequest(req);
|
||||
const user = cookieUser || userFromPaneToken(req);
|
||||
if (!user) return deny(res, 401, "EAUTH", "log in first");
|
||||
req.user = user;
|
||||
// A password an admin gave: the login may only read its own account and
|
||||
// pick its own password until it has.
|
||||
if (cookieUser?.mustChangePassword && !mayBeforePasswordChange(req.method, lp)) {
|
||||
return deny(res, 403, "EPWCHANGE", "choose your own password first");
|
||||
}
|
||||
const under = (x) => underPrefix(p, x);
|
||||
const isRead = req.method === "GET" || req.method === "HEAD";
|
||||
if (
|
||||
@@ -655,8 +758,10 @@ function isWebSocketLoggedIn(req) {
|
||||
}
|
||||
if (host !== String(req.headers.host || "").toLowerCase() || !isHostAllowed(host)) return false;
|
||||
}
|
||||
// Which terminals a login may open is the run check's (websocket.js).
|
||||
return Boolean(userFromRequest(req));
|
||||
// Which terminals a login may open is the run check's (websocket.js). None
|
||||
// until a password an admin gave is replaced.
|
||||
const user = userFromRequest(req);
|
||||
return Boolean(user && !user.mustChangePassword);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -684,8 +789,13 @@ module.exports = {
|
||||
validateProfile,
|
||||
setProfile,
|
||||
validatePassword,
|
||||
validateOwnPassword,
|
||||
passwordProblems,
|
||||
assertNotCurrent,
|
||||
PASSWORD_MIN_LENGTH,
|
||||
endSessions,
|
||||
loginStats,
|
||||
allLoginStats,
|
||||
hasAdmin,
|
||||
userFromRequest,
|
||||
login,
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @file avatar-url.js
|
||||
* @description Fetches an avatar picture from a URL the account typed
|
||||
* (Settings → Account, `POST /api/me/avatar/url`), so the server — not the
|
||||
* browser, which most image hosts' CORS would block — downloads it. A URL the
|
||||
* user names makes the SERVER connect somewhere, so it is held to: `https://`
|
||||
* with no userinfo, a host whose EVERY resolved address is public (no
|
||||
* loopback, private, link-local, CGNAT, multicast or reserved range — the
|
||||
* dashboard's own network is never reachable this way), the connection pinned
|
||||
* to the vetted address (a second DNS answer cannot rebind it), at most
|
||||
* `MAX_REDIRECTS` redirects each re-checked the same way, a timeout, and at
|
||||
* most `MAX_AVATAR_BYTES` read. No cookie or credential is ever sent. The
|
||||
* bytes then go through `setAvatar`'s own magic-byte check like an upload.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const dns = require("node:dns");
|
||||
const https = require("node:https");
|
||||
const net = require("node:net");
|
||||
const { MAX_AVATAR_BYTES } = require("./avatars");
|
||||
|
||||
const MAX_REDIRECTS = 3;
|
||||
const TIMEOUT_MS = 10_000;
|
||||
|
||||
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||
|
||||
const blocked = new net.BlockList();
|
||||
for (const [addr, bits] of [
|
||||
["0.0.0.0", 8],
|
||||
["10.0.0.0", 8],
|
||||
["100.64.0.0", 10],
|
||||
["127.0.0.0", 8],
|
||||
["169.254.0.0", 16],
|
||||
["172.16.0.0", 12],
|
||||
["192.0.0.0", 24],
|
||||
["192.0.2.0", 24],
|
||||
["192.88.99.0", 24],
|
||||
["192.168.0.0", 16],
|
||||
["198.18.0.0", 15],
|
||||
["198.51.100.0", 24],
|
||||
["203.0.113.0", 24],
|
||||
["224.0.0.0", 4],
|
||||
["240.0.0.0", 4],
|
||||
]) {
|
||||
blocked.addSubnet(addr, bits, "ipv4");
|
||||
}
|
||||
for (const [addr, bits] of [
|
||||
["::", 128],
|
||||
["::1", 128],
|
||||
["64:ff9b::", 96],
|
||||
["64:ff9b:1::", 48],
|
||||
["100::", 64],
|
||||
["2001::", 23],
|
||||
["2001:db8::", 32],
|
||||
["2002::", 16],
|
||||
["fc00::", 7],
|
||||
["fe80::", 10],
|
||||
["ff00::", 8],
|
||||
]) {
|
||||
blocked.addSubnet(addr, bits, "ipv6");
|
||||
}
|
||||
|
||||
/** True only for a globally routable unicast address. */
|
||||
function isPublicAddress(address) {
|
||||
const family = net.isIP(address);
|
||||
if (family === 4) return !blocked.check(address, "ipv4");
|
||||
if (family !== 6) return false;
|
||||
const lower = address.toLowerCase();
|
||||
// An IPv4-mapped/compatible address is judged as the IPv4 address it carries.
|
||||
const mapped = /^::(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
|
||||
if (mapped) return isPublicAddress(mapped[1]);
|
||||
if (/^::(?:ffff:)?[0-9a-f]{1,4}:[0-9a-f]{1,4}$/.test(lower)) return false;
|
||||
// Only global unicast (2000::/3) is public.
|
||||
if (!/^[23][0-9a-f]{0,3}:/.test(lower)) return false;
|
||||
return !blocked.check(address, "ipv6");
|
||||
}
|
||||
|
||||
/** The URL as a `URL`, or EBADURL: https only, no userinfo, a real host. */
|
||||
function parseAvatarUrl(text) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(text ?? "").trim());
|
||||
} catch {
|
||||
throw fail("EBADURL", "not a valid URL");
|
||||
}
|
||||
if (url.protocol !== "https:") throw fail("EBADURL", "only an https:// link");
|
||||
if (url.username || url.password) throw fail("EBADURL", "a link with a login is refused");
|
||||
if (!url.hostname) throw fail("EBADURL", "not a valid URL");
|
||||
// Node never calls `lookup` for an IP-literal host, so it is checked here.
|
||||
const literal = url.hostname.replace(/^\[|\]$/g, "");
|
||||
if (net.isIP(literal) && !isPublicAddress(literal)) {
|
||||
throw fail("EPRIVATEURL", "that address is not on the public internet");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* `lookup` for `https.request`: resolves every address and refuses the host
|
||||
* when ANY of them is not public, then hands over only vetted ones.
|
||||
*/
|
||||
function safeLookup(hostname, options, cb) {
|
||||
if (typeof options === "function") [cb, options] = [options, {}];
|
||||
const literal = hostname.replace(/^\[|\]$/g, "");
|
||||
const done = (addrs) => {
|
||||
if (!addrs.length || !addrs.every((a) => isPublicAddress(a.address))) {
|
||||
return cb(fail("EPRIVATEURL", "that address is not on the public internet"));
|
||||
}
|
||||
if (options?.all) return cb(null, addrs);
|
||||
return cb(null, addrs[0].address, addrs[0].family);
|
||||
};
|
||||
if (net.isIP(literal)) return done([{ address: literal, family: net.isIP(literal) }]);
|
||||
dns.lookup(hostname, { all: true, verbatim: true }, (err, addrs) => {
|
||||
if (err) return cb(fail("EFETCH", `could not resolve ${hostname}`));
|
||||
done(addrs);
|
||||
});
|
||||
}
|
||||
|
||||
function getOnce(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(
|
||||
url,
|
||||
{
|
||||
lookup: safeLookup,
|
||||
agent: false,
|
||||
timeout: TIMEOUT_MS,
|
||||
headers: { Accept: "image/png,image/jpeg,image/gif,image/webp", "User-Agent": "AgentHub" },
|
||||
},
|
||||
(res) => {
|
||||
const status = res.statusCode || 0;
|
||||
if (status >= 300 && status < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
return resolve({ redirect: new URL(res.headers.location, url).href });
|
||||
}
|
||||
if (status !== 200) {
|
||||
res.resume();
|
||||
return reject(fail("EFETCH", `the server answered ${status}`));
|
||||
}
|
||||
if (Number(res.headers["content-length"]) > MAX_AVATAR_BYTES) {
|
||||
res.destroy();
|
||||
return reject(fail("ETOOBIG", "the image is too large"));
|
||||
}
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
res.on("data", (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_AVATAR_BYTES) {
|
||||
res.destroy();
|
||||
return reject(fail("ETOOBIG", "the image is too large"));
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on("end", () => resolve({ body: Buffer.concat(chunks) }));
|
||||
res.on("error", (e) => reject(e.code ? e : fail("EFETCH", e.message)));
|
||||
}
|
||||
);
|
||||
req.on("timeout", () => req.destroy(fail("EFETCH", "the server took too long")));
|
||||
req.on("error", (e) =>
|
||||
reject(e.code === "EPRIVATEURL" || e.code === "EFETCH" ? e : fail("EFETCH", e.message))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Downloads the picture at `text`; resolves to its bytes (unchecked — `setAvatar` sniffs them). */
|
||||
async function fetchAvatarImage(text) {
|
||||
let url = parseAvatarUrl(text);
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
const out = await getOnce(url);
|
||||
if (out.body) return out.body;
|
||||
url = parseAvatarUrl(out.redirect);
|
||||
}
|
||||
throw fail("EFETCH", "too many redirects");
|
||||
}
|
||||
|
||||
module.exports = { fetchAvatarImage, isPublicAddress, parseAvatarUrl };
|
||||
@@ -215,10 +215,15 @@ function readSkillsAt(scope, claudeDir) {
|
||||
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{scope?: string, cwd?: string, userClaudeDir?: string}} opts
|
||||
* `userClaudeDir` = whose user scope to read (a member's `~/.claude` under
|
||||
* S3); the host's Claude home when absent.
|
||||
*/
|
||||
function readSkills(opts = {}) {
|
||||
const out = [];
|
||||
if (opts.scope !== "project") {
|
||||
out.push(...readSkillsAt("user", getClaudeHome()));
|
||||
out.push(...readSkillsAt("user", opts.userClaudeDir || getClaudeHome()));
|
||||
}
|
||||
if (opts.scope !== "user") {
|
||||
out.push(...readSkillsAt("project", getProjectClaudeDir(opts.cwd)));
|
||||
@@ -257,7 +262,7 @@ function readSimpleMdSurface(subdir) {
|
||||
return (opts = {}) => {
|
||||
const out = [];
|
||||
if (opts.scope !== "project") {
|
||||
out.push(...readMdFilesAt("user", getClaudeHome(), subdir));
|
||||
out.push(...readMdFilesAt("user", opts.userClaudeDir || getClaudeHome(), subdir));
|
||||
}
|
||||
if (opts.scope !== "user") {
|
||||
out.push(...readMdFilesAt("project", getProjectClaudeDir(opts.cwd), subdir));
|
||||
@@ -423,18 +428,18 @@ function readPluginContributions(installPath) {
|
||||
};
|
||||
}
|
||||
|
||||
function readEnabledPluginsMap() {
|
||||
const userSettings = readJson(path.join(getClaudeHome(), "settings.json"));
|
||||
function readEnabledPluginsMap(home = getClaudeHome()) {
|
||||
const userSettings = readJson(path.join(home, "settings.json"));
|
||||
if (!userSettings.ok || !userSettings.data) return {};
|
||||
const ep = userSettings.data.enabledPlugins;
|
||||
return ep && typeof ep === "object" ? ep : {};
|
||||
}
|
||||
|
||||
function readPlugins() {
|
||||
const home = getClaudeHome();
|
||||
/** @param {string} [home] whose Claude home (a member's under S3); the host's by default. */
|
||||
function readPlugins(home = getClaudeHome()) {
|
||||
const manifestPath = path.join(home, "plugins", "installed_plugins.json");
|
||||
const manifest = readJson(manifestPath);
|
||||
const enabledMap = readEnabledPluginsMap();
|
||||
const enabledMap = readEnabledPluginsMap(home);
|
||||
const plugins = [];
|
||||
if (manifest.ok && manifest.data && manifest.data.plugins) {
|
||||
for (const [pluginKey, instances] of Object.entries(manifest.data.plugins)) {
|
||||
@@ -847,7 +852,11 @@ function isSecretConfigPath(resolved) {
|
||||
const base = path.basename(resolved);
|
||||
if (!base.endsWith(".md") && require("./ai-tools").CONFIG_SECRET_RE.test(base)) return true;
|
||||
const rel = path.relative(getClaudeHome(), resolved);
|
||||
if (!rel.startsWith("..") && !path.isAbsolute(rel) && rel.split(path.sep)[0].startsWith("agenthub-"))
|
||||
if (
|
||||
!rel.startsWith("..") &&
|
||||
!path.isAbsolute(rel) &&
|
||||
rel.split(path.sep)[0].startsWith("agenthub-")
|
||||
)
|
||||
return true;
|
||||
// AgentHub's data dir is no Claude config: it holds the database, the push
|
||||
// keys and every gateway pane's settings file WITH its live pane token.
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
* is resolved only when exactly such a path still exists, else left empty.
|
||||
* A file is re-read when its mtime changes, and the mtime per file lives in
|
||||
* `cursor_transcripts`, so a session the user deleted stays deleted until
|
||||
* Cursor writes to it again. No transcript is ever stored as `transcript_path`.
|
||||
* Cursor writes to it again. A session a dashboard pane started is claimed
|
||||
* for that pane's workspace and run (`claimToolSessions`), which gives the
|
||||
* pane its session id and so its Chat view. No transcript is ever stored as
|
||||
* `transcript_path`; the conversation view finds the file through
|
||||
* `transcriptPath` instead.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -26,6 +30,7 @@ const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { db, stmts } = require("../db");
|
||||
const { claimToolSessions } = require("./dashboard-runs");
|
||||
|
||||
const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active
|
||||
const FUTURE_SLACK_MS = 1000;
|
||||
@@ -148,6 +153,14 @@ function transcriptFilesIn(cursorDir) {
|
||||
}
|
||||
|
||||
const getSeen = db.prepare("SELECT * FROM cursor_transcripts WHERE path = ?");
|
||||
const getPathOf = db.prepare(
|
||||
"SELECT path FROM cursor_transcripts WHERE session_id = ? ORDER BY mtime_ms DESC LIMIT 1"
|
||||
);
|
||||
|
||||
/** The transcript a Cursor session was read from (recorded by the poller), or null. */
|
||||
function transcriptPath(sessionId) {
|
||||
return getPathOf.get(sessionId)?.path || null;
|
||||
}
|
||||
const upsertSeen = db.prepare(
|
||||
`INSERT INTO cursor_transcripts (path, session_id, mtime_ms, updated_at)
|
||||
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
@@ -235,6 +248,7 @@ function ingestCursorUsage({ broadcast = () => {}, now = Date.now() } = {}) {
|
||||
upsertSeen.run(f.path, f.id, f.mtime);
|
||||
if (type) {
|
||||
written += 1;
|
||||
claimToolSessions(f.id); // its workspace and live pane, before the broadcast
|
||||
broadcast(type, stmts.getSession.get(f.id));
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -273,4 +287,5 @@ module.exports = {
|
||||
firstPrompt,
|
||||
ingestCursorUsage,
|
||||
startCursorUsagePoll,
|
||||
transcriptPath,
|
||||
};
|
||||
|
||||
@@ -90,7 +90,7 @@ function bindable(v) {
|
||||
*/
|
||||
/** A run row as it may leave this machine: never a pane credential's hash. */
|
||||
function exportableRun(row) {
|
||||
const { pane_token_hash: _secret, ...rest } = row;
|
||||
const { pane_token_hash: _secret, chat_token_hash: _chat, ...rest } = row;
|
||||
return rest;
|
||||
}
|
||||
|
||||
@@ -185,7 +185,9 @@ function importExportBundle(db, bundle) {
|
||||
events: makeInserter(db, "events", { omit: ["id"], wrap: { data: "trim_event_data" } }),
|
||||
token_usage: makeInserter(db, "token_usage"),
|
||||
workflows: makeInserter(db, "workflows"),
|
||||
dashboard_runs: makeInserter(db, "dashboard_runs", { omit: ["pane_token_hash"] }),
|
||||
dashboard_runs: makeInserter(db, "dashboard_runs", {
|
||||
omit: ["pane_token_hash", "chat_token_hash"],
|
||||
}),
|
||||
alert_rules: makeInserter(db, "alert_rules"),
|
||||
model_pricing: makeInserter(db, "model_pricing"),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
/**
|
||||
* @file omp-share.js
|
||||
* @description Server mode with S3: the HOST account's Oh My Pi (omp) login,
|
||||
* shared with every member's omp pane without any member ever holding it.
|
||||
* An admin's switch (Settings → AI tools → Oh My Pi login sharing,
|
||||
* `GET/PUT /api/ai-tools/omp-sharing`), off by default.
|
||||
*
|
||||
* While it is on, this server keeps two omp daemons of its own running, as the
|
||||
* host, on loopback only:
|
||||
* - `omp auth-broker serve` over the host's own `~/.omp` credential store —
|
||||
* the vault. Its bearer token (`~/.omp/auth-broker.token`, 0600 in the
|
||||
* host's home) reaches nothing but the gateway's env.
|
||||
* - `omp auth-gateway serve --clients=<file>` — an API gateway that answers
|
||||
* `/v1/messages`, `/v1/chat/completions`, `/v1/responses`, `/v1/models`
|
||||
* with the broker's credentials put in upstream. Its own unscoped token
|
||||
* stays in the host's home too.
|
||||
* A member's omp pane reaches the gateway as a SCOPED client: the clients file
|
||||
* lists the SHA-256 of each live omp pane's own pane token
|
||||
* (`dashboard_runs.pane_token_hash`, the same hash `auth.js` keeps), and its
|
||||
* `~/.omp/agent/models.yml` gets one `agenthub` provider whose `apiKey` is the
|
||||
* env var NAME `AGENTHUB_PANE_TOKEN`. A scoped client can call models but
|
||||
* never read a credential or the gateway's usage, and its token stops working
|
||||
* within the gateway's 10 s re-read once its pane is gone (`syncClients`) — so
|
||||
* an agent can spend through the login while its pane lives, never carry it
|
||||
* away. omp's gateway serves only its built-in providers' models (a custom
|
||||
* `models.yml` provider of the host, such as a 9Router, is not shared).
|
||||
*
|
||||
* Nothing here returns or logs a token. Local mode and a server without S3
|
||||
* never start anything.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
const PROVIDER = "agenthub";
|
||||
const TOKEN_ENV = "AGENTHUB_PANE_TOKEN";
|
||||
const SYNC_MS = 15_000;
|
||||
/** A spawn's token counts before its run row exists (recordRun runs after). */
|
||||
const PENDING_MS = 60_000;
|
||||
|
||||
const port = (name, fallback) => {
|
||||
const n = parseInt(process.env[name] || "", 10);
|
||||
return Number.isInteger(n) && n > 0 && n < 65536 ? n : fallback;
|
||||
};
|
||||
const brokerPort = () => port("AGENTHUB_OMP_BROKER_PORT", 4826);
|
||||
const gatewayPort = () => port("AGENTHUB_OMP_GATEWAY_PORT", 4827);
|
||||
|
||||
const dir = () => path.join(require("./claude-home").getDataDir(), "omp-share");
|
||||
const onFlag = () => path.join(dir(), "enabled");
|
||||
const clientsFile = () => path.join(dir(), "clients.json");
|
||||
const pidsFile = () => path.join(dir(), "pids.json");
|
||||
const logFile = (name) => path.join(dir(), `${name}.log`);
|
||||
/** The host's own omp config dir, where the broker keeps its token. */
|
||||
const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
|
||||
|
||||
function available() {
|
||||
return require("./linux-accounts").s3Enabled();
|
||||
}
|
||||
|
||||
const enabled = () => fs.existsSync(onFlag());
|
||||
|
||||
function ensureDir() {
|
||||
fs.mkdirSync(dir(), { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
// ── the clients file ──────────────────────────────────────────────────────
|
||||
|
||||
const pending = new Map(); // run id → { hash, at }
|
||||
let lastClients = null;
|
||||
|
||||
/** `{ <run id>: { tokenSha256 } }` for every live omp pane, or null when tmux could not say. */
|
||||
function liveClients() {
|
||||
const live = require("./tmux").liveSessions();
|
||||
if (!live) return null;
|
||||
const rows = require("../db")
|
||||
.db.prepare(
|
||||
`SELECT id, pane_token_hash FROM dashboard_runs
|
||||
WHERE tool = 'omp' AND status IN ('running', 'spawning') AND pane_token_hash IS NOT NULL`
|
||||
)
|
||||
.all();
|
||||
const out = {};
|
||||
for (const r of rows) if (live.has(r.id)) out[r.id] = { tokenSha256: r.pane_token_hash };
|
||||
const now = Date.now();
|
||||
for (const [id, p] of pending) {
|
||||
if (now - p.at > PENDING_MS) pending.delete(id);
|
||||
else out[id] = { tokenSha256: p.hash };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function writeClients(clients) {
|
||||
const text = `${JSON.stringify(clients, null, 2)}\n`;
|
||||
if (text === lastClients) return;
|
||||
ensureDir();
|
||||
const tmp = `${clientsFile()}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, text, { mode: 0o600 });
|
||||
fs.renameSync(tmp, clientsFile());
|
||||
lastClients = text;
|
||||
}
|
||||
|
||||
/** Rewrite the clients file from the live omp panes (a gone pane's token drops out). */
|
||||
function syncClients() {
|
||||
if (!enabled() || !available()) return;
|
||||
try {
|
||||
const clients = liveClients();
|
||||
if (clients) writeClients(clients);
|
||||
} catch (err) {
|
||||
console.warn("omp share: clients sync failed:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── a member's models.yml ─────────────────────────────────────────────────
|
||||
|
||||
function providerEntry() {
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${gatewayPort()}`,
|
||||
apiKey: TOKEN_ENV,
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
discovery: { type: "proxy" },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of `models.yml` with our provider set, or null when it is already
|
||||
* there as is. Throws on a file that is not a YAML mapping — it is the
|
||||
* member's, and is never overwritten with something it did not say.
|
||||
*/
|
||||
function mergedModels(text) {
|
||||
const yaml = require("js-yaml");
|
||||
const doc = text && text.trim() ? yaml.load(text) : {};
|
||||
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
||||
throw new Error("models.yml is not a YAML mapping");
|
||||
}
|
||||
if (
|
||||
doc.providers != null &&
|
||||
(typeof doc.providers !== "object" || Array.isArray(doc.providers))
|
||||
) {
|
||||
throw new Error("models.yml `providers` is not a mapping");
|
||||
}
|
||||
const want = providerEntry();
|
||||
const have = doc.providers?.[PROVIDER];
|
||||
if (have && JSON.stringify(have) === JSON.stringify(want)) return null;
|
||||
doc.providers = { ...(doc.providers || {}), [PROVIDER]: want };
|
||||
return yaml.dump(doc, { lineWidth: -1, noRefs: true });
|
||||
}
|
||||
|
||||
/** Point a member's omp at the gateway: their `~/.omp/agent/models.yml`, written AS them. */
|
||||
function writeMemberModels(acct) {
|
||||
const runAs = require("./run-as");
|
||||
const agentDir = path.join(acct.home, ".omp", "agent");
|
||||
const file = path.join(agentDir, "models.yml");
|
||||
let text = "";
|
||||
try {
|
||||
text = runAs.readFileSafe(file, "utf8");
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") throw err;
|
||||
runAs.mkdirAs(acct, agentDir);
|
||||
}
|
||||
const next = mergedModels(text);
|
||||
if (next !== null) runAs.writeAs(acct, file, "replace", next);
|
||||
}
|
||||
|
||||
/** The host's own default omp model (`modelRoles.default` in `~/.omp/agent/config.yml`), or null. */
|
||||
function hostDefaultModel() {
|
||||
try {
|
||||
const doc = require("js-yaml").load(
|
||||
fs.readFileSync(path.join(ompDir(), "agent", "config.yml"), "utf8")
|
||||
);
|
||||
const m = doc?.modelRoles?.default;
|
||||
return typeof m === "string" && m.includes("/") ? m : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `--model` a shared pane runs on: the requested model, or the host's
|
||||
* default, as the `agenthub` provider's id (`agenthub/<provider>/<model>`).
|
||||
* Without it omp falls back to the provider's FIRST listed model — a retired
|
||||
* `claude-3-5-sonnet` on the gateway, which answers 404 and retries forever —
|
||||
* or to a built-in provider the member has no login for.
|
||||
*/
|
||||
function shareModel(requested, hostDefault = hostDefaultModel()) {
|
||||
const want = requested || hostDefault;
|
||||
if (!want) return requested || null;
|
||||
if (want.startsWith(`${PROVIDER}/`)) return want;
|
||||
if (want.includes("/")) return `${PROVIDER}/${want}`;
|
||||
const provider = hostDefault ? hostDefault.split("/")[0] : "anthropic";
|
||||
return `${PROVIDER}/${provider}/${want}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by `spawnRun` for an omp pane: `{env, model(), place()}` — env to add,
|
||||
* the `--model` to run on (`shareModel`) and the home to prepare — or
|
||||
* null when sharing is off (the pane then runs on the member's own login).
|
||||
* Only a MEMBER's pane — the host's omp already holds the login itself.
|
||||
*/
|
||||
function forSpawn(member, runId, tokenHash) {
|
||||
if (!member || !tokenHash || !enabled() || !available()) return null;
|
||||
return {
|
||||
env: { OMP_SKIP_SETUP: "1" },
|
||||
model: (requested) => shareModel(requested),
|
||||
place() {
|
||||
try {
|
||||
writeMemberModels(member);
|
||||
} catch (err) {
|
||||
console.warn(`omp share: ${member.login}'s models.yml left as is:`, err.message);
|
||||
}
|
||||
pending.set(runId, { hash: tokenHash, at: Date.now() });
|
||||
syncClients();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── the two daemons ───────────────────────────────────────────────────────
|
||||
|
||||
const procs = { broker: null, gateway: null };
|
||||
const status = { broker: null, gateway: null }; // { pid, startedAt } | { error }
|
||||
let timers = [];
|
||||
let stopping = false;
|
||||
|
||||
function ompBinary() {
|
||||
return require("./ai-tools").detect("omp").path;
|
||||
}
|
||||
|
||||
function readPids() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(pidsFile(), "utf8")) || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writePids() {
|
||||
ensureDir();
|
||||
const pids = {};
|
||||
for (const [k, p] of Object.entries(procs)) if (p?.pid) pids[k] = p.pid;
|
||||
fs.writeFileSync(pidsFile(), JSON.stringify(pids), { mode: 0o600 });
|
||||
}
|
||||
|
||||
/** A daemon an earlier server left behind (KillMode=process keeps children): ours by its argv only. */
|
||||
function killStale() {
|
||||
for (const [name, pid] of Object.entries(readPids())) {
|
||||
if (!Number.isInteger(pid)) continue;
|
||||
let argv = "";
|
||||
try {
|
||||
argv = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (argv.includes(`auth-${name}\0serve`)) {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch {
|
||||
/* gone already */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function portOpen(p) {
|
||||
return new Promise((resolve) => {
|
||||
const s = net.connect({ host: "127.0.0.1", port: p });
|
||||
s.once("connect", () => (s.destroy(), resolve(true)));
|
||||
s.once("error", () => resolve(false));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitPort(p, ms = 15_000) {
|
||||
for (const end = Date.now() + ms; Date.now() < end;) {
|
||||
if (await portOpen(p)) return true;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The env a daemon gets: the host's basics only, never AgentHub's own secrets. */
|
||||
function daemonEnv(extra = {}) {
|
||||
const keep = ["HOME", "USER", "LOGNAME", "PATH", "LANG", "LC_ALL", "TMPDIR"];
|
||||
const env = {};
|
||||
for (const k of keep) if (process.env[k]) env[k] = process.env[k];
|
||||
return { ...env, OMP_SKIP_SETUP: "1", ...extra };
|
||||
}
|
||||
|
||||
function launch(name, bin, args, env) {
|
||||
ensureDir();
|
||||
const out = fs.openSync(logFile(name), "a", 0o600);
|
||||
const child = spawn(bin, args, { env, stdio: ["ignore", out, out], windowsHide: true });
|
||||
fs.closeSync(out);
|
||||
procs[name] = child;
|
||||
status[name] = { pid: child.pid, startedAt: new Date().toISOString() };
|
||||
child.on("error", (err) => (status[name] = { error: err.message }));
|
||||
child.on("exit", (code, signal) => {
|
||||
// One this module stopped (or replaced) itself is no crash.
|
||||
if (procs[name] !== child) return;
|
||||
procs[name] = null;
|
||||
if (stopping || !enabled()) return;
|
||||
status[name] = { error: `exited (${signal || code})` };
|
||||
// The pair restarts together: the gateway holds the broker's token.
|
||||
restartSoon();
|
||||
});
|
||||
writePids();
|
||||
return child;
|
||||
}
|
||||
|
||||
let restartTimer = null;
|
||||
let backoff = 5_000;
|
||||
function restartSoon() {
|
||||
if (restartTimer) return;
|
||||
restartTimer = setTimeout(() => {
|
||||
restartTimer = null;
|
||||
stopDaemons();
|
||||
start().catch(() => {});
|
||||
backoff = Math.min(backoff * 2, 5 * 60_000);
|
||||
}, backoff);
|
||||
if (restartTimer.unref) restartTimer.unref();
|
||||
}
|
||||
|
||||
async function startDaemons() {
|
||||
const bin = ompBinary();
|
||||
if (!bin) {
|
||||
status.broker = status.gateway = { error: "omp is not installed" };
|
||||
return;
|
||||
}
|
||||
killStale();
|
||||
// The gateway refuses to start without the clients file it names.
|
||||
if (!fs.existsSync(clientsFile())) writeClients({});
|
||||
const bport = brokerPort();
|
||||
const gport = gatewayPort();
|
||||
for (const p of [bport, gport]) {
|
||||
if (await waitPortClosed(p)) continue;
|
||||
status.broker = status.gateway = { error: `port ${p} is taken` };
|
||||
return;
|
||||
}
|
||||
launch("broker", bin, ["auth-broker", "serve", `--bind=127.0.0.1:${bport}`], daemonEnv());
|
||||
if (!(await waitPort(bport))) {
|
||||
status.broker = { error: "the broker did not start listening" };
|
||||
return restartSoon();
|
||||
}
|
||||
let token;
|
||||
try {
|
||||
token = fs.readFileSync(path.join(ompDir(), "auth-broker.token"), "utf8").trim();
|
||||
} catch {
|
||||
status.broker = { error: "the broker wrote no token" };
|
||||
return restartSoon();
|
||||
}
|
||||
launch(
|
||||
"gateway",
|
||||
bin,
|
||||
["auth-gateway", "serve", `--bind=127.0.0.1:${gport}`, `--clients=${clientsFile()}`],
|
||||
daemonEnv({
|
||||
OMP_AUTH_BROKER_URL: `http://127.0.0.1:${bport}`,
|
||||
OMP_AUTH_BROKER_TOKEN: token,
|
||||
})
|
||||
);
|
||||
if (await waitPort(gport)) backoff = 5_000;
|
||||
else status.gateway = { error: "the gateway did not start listening" };
|
||||
}
|
||||
|
||||
/** A port a previous daemon is still letting go of gets a few seconds. */
|
||||
async function waitPortClosed(p, ms = 5_000) {
|
||||
for (const end = Date.now() + ms; Date.now() < end;) {
|
||||
if (!(await portOpen(p))) return true;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function stopDaemons() {
|
||||
for (const [name, child] of Object.entries(procs)) {
|
||||
if (!child) continue;
|
||||
procs[name] = null;
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
/* gone already */
|
||||
}
|
||||
}
|
||||
try {
|
||||
fs.rmSync(pidsFile(), { force: true });
|
||||
} catch {
|
||||
/* nothing recorded */
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the daemons and the clients sync — when sharing is on, under S3 only. */
|
||||
async function start() {
|
||||
stopping = false;
|
||||
if (!available() || !enabled()) return;
|
||||
if (!timers.length) {
|
||||
const t = setInterval(syncClients, SYNC_MS);
|
||||
if (t.unref) t.unref();
|
||||
timers.push(t);
|
||||
}
|
||||
syncClients();
|
||||
if (procs.broker || procs.gateway) return;
|
||||
await startDaemons();
|
||||
}
|
||||
|
||||
/** Stop everything this module started (the server's shutdown, or the switch turned off). */
|
||||
function stop() {
|
||||
stopping = true;
|
||||
for (const t of timers) clearInterval(t);
|
||||
timers = [];
|
||||
if (restartTimer) clearTimeout(restartTimer);
|
||||
restartTimer = null;
|
||||
stopDaemons();
|
||||
status.broker = status.gateway = null;
|
||||
}
|
||||
|
||||
async function setEnabled(on) {
|
||||
ensureDir();
|
||||
if (on) {
|
||||
fs.writeFileSync(onFlag(), "", { mode: 0o600 });
|
||||
await start();
|
||||
} else {
|
||||
fs.rmSync(onFlag(), { force: true });
|
||||
stop();
|
||||
// No pane keeps a way in once sharing is off.
|
||||
lastClients = null;
|
||||
pending.clear();
|
||||
if (fs.existsSync(clientsFile())) writeClients({});
|
||||
}
|
||||
return state();
|
||||
}
|
||||
|
||||
/** What the settings card shows — never a token. */
|
||||
function state() {
|
||||
const live = (name) => (procs[name] ? status[name] : null);
|
||||
return {
|
||||
available: available(),
|
||||
enabled: enabled(),
|
||||
installed: !!ompBinary(),
|
||||
broker: live("broker")
|
||||
? { running: true }
|
||||
: { running: false, error: status.broker?.error || null },
|
||||
gateway: live("gateway")
|
||||
? { running: true, port: gatewayPort() }
|
||||
: { running: false, error: status.gateway?.error || null },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
available,
|
||||
enabled,
|
||||
setEnabled,
|
||||
start,
|
||||
stop,
|
||||
state,
|
||||
forSpawn,
|
||||
syncClients,
|
||||
// exported for the tests
|
||||
mergedModels,
|
||||
providerEntry,
|
||||
shareModel,
|
||||
liveClients,
|
||||
PROVIDER,
|
||||
TOKEN_ENV,
|
||||
};
|
||||
+24
-2
@@ -302,7 +302,11 @@ function buildCodexArgv({
|
||||
* file, and the prompt is one argv element — or, for `after-start`, nothing
|
||||
* here: spawnRun pastes it into the pane once the CLI is up.
|
||||
*/
|
||||
function buildCatalogArgv(entry, bin, { model, resumeSessionId, resumeLast, bypass, initialPrompt }) {
|
||||
function buildCatalogArgv(
|
||||
entry,
|
||||
bin,
|
||||
{ model, resumeSessionId, resumeLast, bypass, initialPrompt }
|
||||
) {
|
||||
const argv = [bin, ...entry.args];
|
||||
if (model && entry.model) argv.push(entry.model.flag, model);
|
||||
if (resumeSessionId && entry.resumeById) argv.push(...entry.resumeById, resumeSessionId);
|
||||
@@ -485,6 +489,16 @@ function spawnRun(args) {
|
||||
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
|
||||
: null;
|
||||
const paneCred = require("./auth").newPaneToken();
|
||||
// A member's omp on the host's shared omp login (omp-share.js): its
|
||||
// models.yml points at the loopback gateway, keyed by this pane's token.
|
||||
const ompShare =
|
||||
tool === "omp"
|
||||
? require("./omp-share").forSpawn(
|
||||
require("./run-as").accountForLane(laneId),
|
||||
runIdForLane(laneId, slot),
|
||||
paneCred?.hash
|
||||
)
|
||||
: null;
|
||||
if (forkSession === true) {
|
||||
if (!resumeSessionId) throw makeErr("EBADSESSION", "a fork needs resumeSessionId");
|
||||
if (entry.launcher !== "claude") {
|
||||
@@ -540,7 +554,8 @@ function spawnRun(args) {
|
||||
systemPrompt,
|
||||
systemPromptFile,
|
||||
tool,
|
||||
model,
|
||||
// A shared omp login runs on the gateway's provider (omp-share.js).
|
||||
model: ompShare ? ompShare.model(model) : model,
|
||||
permissionMode,
|
||||
effort,
|
||||
sandbox,
|
||||
@@ -640,6 +655,11 @@ function spawnRun(args) {
|
||||
...(entry.launcher === "claude" && systemPromptFile
|
||||
? { CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: "1" }
|
||||
: {}),
|
||||
// Claude Code hides TaskCreate/TaskUpdate from its newer models unless
|
||||
// this is set, and those calls are all the chat Progress panel draws
|
||||
// (routes/sessions.js sessionOutline). A user's settings.json `env` still
|
||||
// wins, so `"0"` there turns them back off.
|
||||
...(entry.launcher === "claude" ? { CLAUDE_CODE_ENABLE_TODO_TOOLS: "1" } : {}),
|
||||
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
|
||||
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
|
||||
// pushes lines into history its redraw cannot clear). The user's own
|
||||
@@ -649,6 +669,7 @@ function spawnRun(args) {
|
||||
// this process only. Through tmux `-e`, never typed into the pane.
|
||||
...(poolAccount ? poolAccount.env : {}),
|
||||
...(codexPool ? codexPool.env : {}),
|
||||
...(ompShare ? ompShare.env : {}),
|
||||
// Server mode: the CLI, MCP server and statusline in this pane act as the
|
||||
// workspace's owner with it (server/lib/auth.js userFromPaneToken).
|
||||
...(paneCred ? { AGENTHUB_PANE_TOKEN: paneCred.token } : {}),
|
||||
@@ -683,6 +704,7 @@ function spawnRun(args) {
|
||||
const prepareHome = () => {
|
||||
codexPool?.place();
|
||||
poolAccount?.place();
|
||||
ompShare?.place();
|
||||
if (member && entry.launcher === "claude" && (poolAccount || proxyProfile)) {
|
||||
require("./run-as").markClaudeOnboarded(member);
|
||||
}
|
||||
|
||||
@@ -288,6 +288,7 @@ function broadcastAudience(type, data) {
|
||||
case "agent_updated":
|
||||
case "new_event":
|
||||
case "workflow_upserted":
|
||||
case "transcript_changed":
|
||||
return { owners: sessionOwners(d.session_id) };
|
||||
case "alert_triggered":
|
||||
case "alert_updated":
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* @file Turns one Claude Code transcript line (or a stream-json event shaped
|
||||
* like one) into the conversation message the chat renders: sender, text,
|
||||
* thinking, tool calls and results (with an edit's +/- counts, an
|
||||
* AskUserQuestion's answers, a subagent's facts), model, usage. Shared by the
|
||||
* transcript route and the realtime chat runtime so both draw the same thing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/**
|
||||
* Classify the TRUE sender of a transcript entry. A JSONL `type:"user"` line is
|
||||
* not always the human: it also carries tool results, harness-injected
|
||||
* task-notifications, /loop re-injections (`isMeta`), and — in a subagent
|
||||
* transcript — the task prompt handed down by the orchestrator. Attributing all
|
||||
* of those to "User" is wrong; the UI styles each sender distinctly.
|
||||
*
|
||||
* Returns: "user" | "assistant" | "orchestrator" | "system" | "tool".
|
||||
* user — a real message typed by the human
|
||||
* assistant — the agent's own turn
|
||||
* orchestrator — a subagent's task, assigned by its parent/main agent
|
||||
* system — harness/tooling injection (task-notification, /loop meta, …)
|
||||
* tool — a tool_result echoed back on a `user` line
|
||||
*/
|
||||
function classifyTranscriptSender(entry, isSubagentFile) {
|
||||
if (entry.type === "assistant") return "assistant";
|
||||
// `system`/local_command lines are surfaced as the human's slash-command I/O.
|
||||
if (entry.type !== "user") return "user";
|
||||
|
||||
const content = entry.message ? entry.message.content : undefined;
|
||||
const onlyToolResults =
|
||||
Array.isArray(content) &&
|
||||
content.length > 0 &&
|
||||
content.every((b) => b && b.type === "tool_result");
|
||||
if (entry.toolUseResult !== undefined || onlyToolResults) return "tool";
|
||||
|
||||
const text =
|
||||
typeof content === "string"
|
||||
? content
|
||||
: Array.isArray(content)
|
||||
? (content.find((b) => b && b.type === "text") || {}).text || ""
|
||||
: "";
|
||||
const lead = text.replace(/^\s+/, "");
|
||||
|
||||
// Harness injections that masquerade as a user line.
|
||||
if (entry.isMeta === true) return "system";
|
||||
if (lead.startsWith("<task-notification>") || lead.startsWith("<task-notification ")) {
|
||||
return "system";
|
||||
}
|
||||
// Background-task event banner ("[SYSTEM NOTIFICATION - NOT USER INPUT] …")
|
||||
// that newer harness builds prefix ahead of the <task-notification> payload.
|
||||
if (lead.startsWith("[SYSTEM NOTIFICATION")) return "system";
|
||||
|
||||
// In a subagent transcript, a user line with no human prompt provenance is the
|
||||
// task injected by the Task/Agent tool. A real human message to the subagent
|
||||
// (rare, but allowed) carries promptSource/origin and stays "user".
|
||||
if (isSubagentFile && entry.promptSource === undefined && entry.origin === undefined) {
|
||||
return "orchestrator";
|
||||
}
|
||||
|
||||
return "user";
|
||||
}
|
||||
|
||||
/** The +/- line counts of a Write/Edit/MultiEdit result (`toolUseResult`):
|
||||
* its `structuredPatch` hunks' `+`/`-` lines, or every line of a file it
|
||||
* created. Null for any other result. */
|
||||
function patchCounts(r) {
|
||||
if (!r || typeof r !== "object") return null;
|
||||
if (Array.isArray(r.structuredPatch) && r.structuredPatch.length) {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const h of r.structuredPatch) {
|
||||
for (const l of Array.isArray(h?.lines) ? h.lines : []) {
|
||||
if (typeof l !== "string") continue;
|
||||
if (l.startsWith("+")) added++;
|
||||
else if (l.startsWith("-")) removed++;
|
||||
}
|
||||
}
|
||||
return { added, removed };
|
||||
}
|
||||
if (r.type === "create" && typeof r.content === "string") {
|
||||
return { added: r.content.split("\n").length - (r.content.endsWith("\n") ? 1 : 0), removed: 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** An AskUserQuestion result's answers (`toolUseResult.answers`): question
|
||||
* text → the option picked, or what the user typed. Null for any other result. */
|
||||
function askAnswers(r) {
|
||||
if (!r || typeof r !== "object" || !r.answers || typeof r.answers !== "object") return null;
|
||||
if (Array.isArray(r.answers)) return null;
|
||||
const out = {};
|
||||
for (const [q, a] of Object.entries(r.answers)) if (typeof a === "string") out[q] = a;
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
/** An Agent/Task result's facts (`toolUseResult`): the subagent's id (its
|
||||
* transcript is `agent-<id>.jsonl`), the model it ran on and, once it
|
||||
* finished in the foreground, its time, tokens and tool calls. Null for any
|
||||
* other result. */
|
||||
function agentInfo(r) {
|
||||
if (!r || typeof r !== "object" || typeof r.agentId !== "string") return null;
|
||||
if (!/^[\w-]{1,200}$/.test(r.agentId)) return null;
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
return {
|
||||
id: r.agentId,
|
||||
model: typeof r.resolvedModel === "string" ? r.resolvedModel : null,
|
||||
status: typeof r.status === "string" ? r.status : null,
|
||||
duration_ms: num(r.totalDurationMs),
|
||||
tokens: num(r.totalTokens),
|
||||
tool_uses: num(r.totalToolUseCount),
|
||||
};
|
||||
}
|
||||
|
||||
// A conversation turn — a prompt, an answer — is shown whole: it is bounded by
|
||||
// the model's output and what the user typed. Only tool payloads and thinking
|
||||
// (the parts that can run to megabytes) keep the 10 KB cap. This one guards
|
||||
// against a pathological line, not a long answer.
|
||||
const TEXT_MAX = 1024 * 1024;
|
||||
// The tools whose input carries a file's text (artifactVersions on the client).
|
||||
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit"]);
|
||||
const EDIT_INPUT_MAX = 256 * 1024;
|
||||
|
||||
function truncate(str, maxLen) {
|
||||
if (!str || str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen) + "[truncated]";
|
||||
}
|
||||
|
||||
function truncateObj(obj, maxLen) {
|
||||
if (!obj) return obj;
|
||||
const json = JSON.stringify(obj);
|
||||
if (json.length <= maxLen) return obj;
|
||||
return { _truncated: truncate(json, maxLen) };
|
||||
}
|
||||
|
||||
/** A parser for one transcript: it remembers the last rename it surfaced, so
|
||||
* a repeated `custom-title` line shows once. `isSubagentFile` tells a
|
||||
* subagent's task (orchestrator) from a human prompt. */
|
||||
function createClaudeLineParser(isSubagentFile = false) {
|
||||
// Dedupe state for synthetic rename markers: custom-title lines can repeat
|
||||
// with the same value across a transcript, so only emit when the title
|
||||
// actually changes from the last one we surfaced.
|
||||
let lastRenameTitle = null;
|
||||
|
||||
// Helper: parse a JSONL line into a message object, or null if not a displayable message
|
||||
function parseMessage(entry, num) {
|
||||
// /rename, `claude -n`, picker Ctrl+R → custom-title metadata line. These
|
||||
// commands produce no user/assistant turn, so without this they'd be
|
||||
// invisible. Surface a compact "renamed" marker instead.
|
||||
if (entry.type === "custom-title") {
|
||||
const title = typeof entry.customTitle === "string" ? entry.customTitle.trim() : "";
|
||||
if (!title || title === lastRenameTitle) return null;
|
||||
lastRenameTitle = title;
|
||||
return {
|
||||
type: "session_event",
|
||||
event_kind: "rename",
|
||||
title,
|
||||
timestamp: entry.timestamp || null,
|
||||
content: [],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
// Local slash-command I/O. Newer Claude Code builds write the command
|
||||
// invocation and its captured output as `system`/`local_command` lines
|
||||
// with the TUI markup in a top-level `content` string (older builds used
|
||||
// `user` messages, handled below). Surface those as user-side text so the
|
||||
// client's tuiSegments parser renders the command pill + stdout/stderr
|
||||
// (e.g. /color → "/color" pill + "Session color set to: cyan"). Skip
|
||||
// every other system subtype (turn_duration, stop_hook_summary, …) and
|
||||
// empty local_command lines (e.g. /clear writes a content-less one).
|
||||
if (entry.type === "system") {
|
||||
if (entry.subtype !== "local_command") return null;
|
||||
const sysText = typeof entry.content === "string" ? entry.content : "";
|
||||
if (!sysText.trim()) return null;
|
||||
return {
|
||||
type: "user",
|
||||
sender: "user", // local slash-command I/O is the human's own action
|
||||
timestamp: entry.timestamp || null,
|
||||
content: [{ type: "text", text: truncate(sysText, TEXT_MAX) }],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
// Mid-turn queued message: journaled as a `queued_command` attachment
|
||||
// (never as a `user` line). Surface it at the position the model actually
|
||||
// received it. NOT everything in the queue is the human, though — the
|
||||
// harness delivers its own injections (task-notifications from background
|
||||
// agents, "[SYSTEM NOTIFICATION …]" banners) through the same queue, and
|
||||
// those attachments carry NO `origin` field, while a genuinely typed
|
||||
// message carries `origin.kind = "human"`. So: harness-marker text or a
|
||||
// non-human origin → "system"; everything else → "user". Other attachment
|
||||
// subtypes are harness noise → dropped.
|
||||
if (entry.type === "attachment") {
|
||||
const att = entry.attachment;
|
||||
if (!att || att.type !== "queued_command") return null;
|
||||
const prompt = typeof att.prompt === "string" ? att.prompt : "";
|
||||
if (!prompt.trim()) return null;
|
||||
const lead = prompt.replace(/^\s+/, "");
|
||||
// Same harness markers classifyTranscriptSender strips off user lines.
|
||||
const isHarnessText =
|
||||
lead.startsWith("<task-notification") || lead.startsWith("[SYSTEM NOTIFICATION");
|
||||
const kind = att.origin && typeof att.origin.kind === "string" ? att.origin.kind : null;
|
||||
const isSystem = isHarnessText || (kind !== null && kind !== "human");
|
||||
return {
|
||||
type: "user",
|
||||
sender: isSystem ? "system" : "user",
|
||||
timestamp: entry.timestamp || att.timestamp || null,
|
||||
content: [{ type: "text", text: truncate(prompt, TEXT_MAX) }],
|
||||
line: num,
|
||||
};
|
||||
}
|
||||
|
||||
const msg = entry.type === "assistant" ? entry.message || {} : {};
|
||||
const content = [];
|
||||
|
||||
if (entry.type === "user") {
|
||||
const msgContent = entry.message?.content;
|
||||
if (typeof msgContent === "string") {
|
||||
content.push({ type: "text", text: truncate(msgContent, TEXT_MAX) });
|
||||
} else if (Array.isArray(msgContent)) {
|
||||
for (const block of msgContent) {
|
||||
if (block.type === "text" && block.text) {
|
||||
content.push({ type: "text", text: truncate(block.text, TEXT_MAX) });
|
||||
} else if (block.type === "tool_result") {
|
||||
// A line carries one tool result; an edit's +/- counts ride on it.
|
||||
const diff = patchCounts(entry.toolUseResult);
|
||||
const answers = askAnswers(entry.toolUseResult);
|
||||
const agent = agentInfo(entry.toolUseResult);
|
||||
// A subagent's report is a reply, read as prose: its text blocks
|
||||
// whole, not a 10 KB slice of their JSON.
|
||||
const report =
|
||||
agent && Array.isArray(block.content)
|
||||
? block.content
|
||||
.filter((c) => c?.type === "text" && typeof c.text === "string")
|
||||
.map((c) => c.text)
|
||||
.join("\n\n")
|
||||
: null;
|
||||
content.push({
|
||||
type: "tool_result",
|
||||
id: block.tool_use_id || null,
|
||||
output:
|
||||
report !== null
|
||||
? truncate(report, TEXT_MAX)
|
||||
: truncate(
|
||||
typeof block.content === "string"
|
||||
? block.content
|
||||
: JSON.stringify(block.content || ""),
|
||||
10240
|
||||
),
|
||||
is_error: !!block.is_error,
|
||||
...(diff ? { diff } : {}),
|
||||
...(answers ? { answers } : {}),
|
||||
...(agent ? { agent } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msgContent === undefined || msgContent === null) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const msgContent = msg.content || [];
|
||||
if (Array.isArray(msgContent)) {
|
||||
for (const block of msgContent) {
|
||||
if (block.type === "text" && block.text) {
|
||||
content.push({ type: "text", text: truncate(block.text, TEXT_MAX) });
|
||||
} else if (block.type === "thinking" && block.thinking) {
|
||||
content.push({ type: "thinking", text: truncate(block.thinking, 10240) });
|
||||
} else if (block.type === "tool_use") {
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
name: block.name || "unknown",
|
||||
id: block.id || null,
|
||||
// A file a Write/Edit made is an artifact the chat can show in
|
||||
// versions, rebuilt from these inputs: they keep up to 256 KB.
|
||||
input: truncateObj(block.input, EDIT_TOOLS.has(block.name) ? EDIT_INPUT_MAX : 10240),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content.length === 0) return null;
|
||||
|
||||
const message = {
|
||||
type: entry.type,
|
||||
sender: classifyTranscriptSender(entry, isSubagentFile),
|
||||
timestamp: entry.timestamp
|
||||
? typeof entry.timestamp === "number"
|
||||
? new Date(entry.timestamp).toISOString()
|
||||
: entry.timestamp
|
||||
: null,
|
||||
content,
|
||||
line: num,
|
||||
};
|
||||
|
||||
if (entry.type === "assistant") {
|
||||
if (msg.model) message.model = msg.model;
|
||||
// "end_turn" closes the turn; "tool_use" means more work follows.
|
||||
if (typeof msg.stop_reason === "string") message.stop_reason = msg.stop_reason;
|
||||
if (msg.usage) {
|
||||
message.usage = {
|
||||
input_tokens: msg.usage.input_tokens || 0,
|
||||
output_tokens: msg.usage.output_tokens || 0,
|
||||
cache_read_input_tokens: msg.usage.cache_read_input_tokens || 0,
|
||||
cache_creation_input_tokens: msg.usage.cache_creation_input_tokens || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
return parseMessage;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
classifyTranscriptSender,
|
||||
patchCounts,
|
||||
askAnswers,
|
||||
agentInfo,
|
||||
truncate,
|
||||
truncateObj,
|
||||
TEXT_MAX,
|
||||
createClaudeLineParser,
|
||||
};
|
||||
@@ -40,7 +40,8 @@ const schemas = {
|
||||
resume: { type: "boolean", description: "Offers Resume last session" },
|
||||
resumeById: {
|
||||
type: "boolean",
|
||||
description: "Can resume a named session (Claude, Codex, a catalog `resumeById` such as omp)",
|
||||
description:
|
||||
"Can resume a named session (Claude, Codex, a catalog `resumeById` such as omp)",
|
||||
},
|
||||
bypass: { type: "boolean", description: "Offers the per-run Bypass permissions switch" },
|
||||
proxyCapable: { type: "boolean", description: "Can use an AI Proxy profile" },
|
||||
@@ -132,6 +133,46 @@ const paths = {
|
||||
responses: ok,
|
||||
},
|
||||
},
|
||||
"/api/ai-tools/omp-sharing": {
|
||||
get: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "Oh My Pi login sharing state",
|
||||
description:
|
||||
"Server mode with per-user accounts (S3): whether every member's omp pane runs on the HOST account's omp login through omp's own auth broker + gateway on loopback (server/lib/omp-share.js), and whether both daemons run. Never returns a token.",
|
||||
operationId: "aiToolsOmpSharing",
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"`{available, enabled, installed, broker: {running, error?}, gateway: {running, port?, error?}}`.",
|
||||
},
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "Turn Oh My Pi login sharing on or off",
|
||||
description:
|
||||
"An admin's switch (off by default). On starts `omp auth-broker serve` and `omp auth-gateway serve --clients=…` as the host; off stops them and empties the clients file, so no pane keeps a way in. Same-origin only; audited.",
|
||||
operationId: "aiToolsSetOmpSharing",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["enabled"],
|
||||
properties: { enabled: { type: "boolean" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "The new state, as GET." },
|
||||
400: { description: "EBADFIELD" },
|
||||
403: { description: "Not an admin" },
|
||||
409: { description: "ENOS3 — not server mode with per-user accounts" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/ai-tools/{id}": {
|
||||
put: {
|
||||
tags: ["AI Tools"],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user