Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebfae9b891 | |||
| 3ab484ddc0 | |||
| 6bcef26446 | |||
| 240444eafd | |||
| d09c9d6633 | |||
| bc44cbc8b7 | |||
| e7060a5e42 | |||
| 4b8f59695b | |||
| da7b86d5c8 | |||
| efe21c79e9 | |||
| ee119fe437 | |||
| d97c403aab | |||
| c41114faf8 | |||
| c850f7603a | |||
| 5463e354fa | |||
| 783b7f4101 | |||
| c28395402b | |||
| 7798530528 | |||
| 25f63d9ad3 | |||
| d060d288fb | |||
| 6099841cef | |||
| d70a7c9403 | |||
| 63eaa739d4 | |||
| 9f8dc9082e | |||
| f1a90e120e | |||
| db5a7776cc | |||
| 6c2c094f64 |
@@ -1,4 +1,8 @@
|
|||||||
{
|
{
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "",
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": ""
|
||||||
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(npm run:*)",
|
"Bash(npm run:*)",
|
||||||
@@ -36,11 +40,8 @@
|
|||||||
"Bash(node -e ':*)",
|
"Bash(node -e ':*)",
|
||||||
"Bash(node -e \"require\\('./server/lib/transcript-cache'\\); require\\('./scripts/import-history'\\); console.log\\('All modules load OK'\\)\")",
|
"Bash(node -e \"require\\('./server/lib/transcript-cache'\\); require\\('./scripts/import-history'\\); console.log\\('All modules load OK'\\)\")",
|
||||||
"Bash(claude --version)",
|
"Bash(claude --version)",
|
||||||
"Bash(gh pr *)"
|
"Bash(gh pr *)",
|
||||||
|
"Bash(grep *)"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"env": {
|
|
||||||
"ANTHROPIC_BASE_URL": "",
|
|
||||||
"ANTHROPIC_AUTH_TOKEN": ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -15,8 +15,10 @@ import { FileText, Loader2 } from "lucide-react";
|
|||||||
import { artifactDocument, frameSandbox } from "../lib/artifactView";
|
import { artifactDocument, frameSandbox } from "../lib/artifactView";
|
||||||
import { blobText, inlineHtml, type FetchFile } from "../lib/htmlInline";
|
import { blobText, inlineHtml, type FetchFile } from "../lib/htmlInline";
|
||||||
|
|
||||||
/** A page read by `fetchFile` is shown up to this size. */
|
/** A page read by `fetchFile` is shown up to this size. A self-contained
|
||||||
const HTML_MAX = 10 * 1024 * 1024;
|
* page (a slide deck with its video and images inlined as base64) is easily
|
||||||
|
* past 10 MB; the iframe renders far larger documents than this. */
|
||||||
|
const HTML_MAX = 64 * 1024 * 1024;
|
||||||
|
|
||||||
export function HtmlFrame({
|
export function HtmlFrame({
|
||||||
path,
|
path,
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
* @description Tests for the sidebar's workspace list: one flat row per
|
* @description Tests for the sidebar's workspace list: one flat row per
|
||||||
* workspace in the stored order, a row expanding to its sessions (remembered),
|
* workspace in the stored order, a row expanding to its sessions (remembered),
|
||||||
* a live session opening its pane and an ended one its transcript, the
|
* a live session opening its pane and an ended one its transcript, the
|
||||||
* workspace's OWN sessions only, a session's state dot, rename and archive,
|
* workspace's OWN sessions only, a session's state dot, rename and archive
|
||||||
|
* (a live one's pane stopped first),
|
||||||
* selection
|
* selection
|
||||||
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
|
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
|
||||||
* search, the row menu's items by kind and tool, the status dot, and the
|
* search, the row menu's items by kind and tool, the status dot, and the
|
||||||
@@ -225,6 +226,8 @@ describe("WorkspaceTree", () => {
|
|||||||
await user.type(input, "Better name{Enter}");
|
await user.type(input, "Better name{Enter}");
|
||||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { name: "Better name" });
|
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { name: "Better name" });
|
||||||
await user.click(screen.getByTestId("workspace-session-archive-s-live"));
|
await user.click(screen.getByTestId("workspace-session-archive-s-live"));
|
||||||
|
// A live session's Archive stops its pane first — never a hidden pane left running.
|
||||||
|
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
|
||||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -683,7 +683,12 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
|
|||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
|
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
|
||||||
>
|
>
|
||||||
<UserAvatar name={name} className="h-6 w-6 text-[10px]" />
|
<UserAvatar
|
||||||
|
name={name}
|
||||||
|
userId={g.userId}
|
||||||
|
avatarAt={g.avatarAt}
|
||||||
|
className="h-6 w-6 text-[10px]"
|
||||||
|
/>
|
||||||
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ const stats: HostStats = {
|
|||||||
const api = vi.hoisted(() => ({
|
const api = vi.hoisted(() => ({
|
||||||
host: { stats: vi.fn(), history: vi.fn() },
|
host: { stats: vi.fn(), history: vi.fn() },
|
||||||
me: { avatarUrl: () => null },
|
me: { avatarUrl: () => null },
|
||||||
|
users: { avatarUrl: () => null },
|
||||||
}));
|
}));
|
||||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<Record<string, unknown>>();
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
|||||||
@@ -1470,25 +1470,40 @@ export function LaneChangesPanel({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Discard all: every non-conflicted file in one `discard`, asked once in a
|
// Discard selected: exactly what Commit would take — the ticked files, and
|
||||||
// dialog that names how many untracked files it will delete.
|
// only the ticked blocks of a partly ticked one (#87: it used to discard
|
||||||
const discardAll = async () => {
|
// every file, ticked or not) — in one `discard`, asked once in a dialog
|
||||||
const rows = files.filter((f) => !f.conflicted);
|
// that names how many untracked files it will delete.
|
||||||
if (!rows.length) return;
|
const discardTicked = async () => {
|
||||||
|
if (!tickedPaths.length) return;
|
||||||
const ok = await confirmDialog({
|
const ok = await confirmDialog({
|
||||||
title: t("changes.discardAllTitle"),
|
title: t("changes.discardTickedTitle"),
|
||||||
message: t("changes.discardAllConfirm", {
|
message: t("changes.discardTickedConfirm", {
|
||||||
count: rows.length,
|
count: tickedPaths.length,
|
||||||
untracked: rows.filter((f) => f.untracked).length,
|
untracked: files.filter((f) => f.untracked && tickedPaths.includes(f.path)).length,
|
||||||
}),
|
}),
|
||||||
confirmLabel: t("changes.discardAll"),
|
confirmLabel: t("changes.discardTicked"),
|
||||||
});
|
});
|
||||||
if (ok)
|
if (!ok) return;
|
||||||
await act(
|
const lane = laneId;
|
||||||
"discard",
|
setBusy(true);
|
||||||
rows.map((f) => f.path),
|
setError(null);
|
||||||
true
|
try {
|
||||||
);
|
const { paths, hunks } = await selection();
|
||||||
|
await api.lanes.gitAction(target, "discard", {
|
||||||
|
paths,
|
||||||
|
...(hunks ? { hunks, context: CONTEXT_STEPS[0] } : {}),
|
||||||
|
confirm: true,
|
||||||
|
});
|
||||||
|
if (laneNow.current !== lane) return;
|
||||||
|
setConfirmDiscard(null);
|
||||||
|
refreshGit();
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
if (laneNow.current === lane) setBusy(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
|
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
|
||||||
@@ -2067,8 +2082,7 @@ export function LaneChangesPanel({
|
|||||||
// An HTML page's relative CSS/scripts/images, read like a download — only
|
// An HTML page's relative CSS/scripts/images, read like a download — only
|
||||||
// from the working tree: a commit's page would get today's files.
|
// from the working tree: a commit's page would get today's files.
|
||||||
const htmlFetch = useMemo(
|
const htmlFetch = useMemo(
|
||||||
() =>
|
() => (range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p))),
|
||||||
range === "commit" ? null : fetchVia((p) => api.lanes.gitDownloadUrl(target, p)),
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[range, JSON.stringify(target)]
|
[range, JSON.stringify(target)]
|
||||||
);
|
);
|
||||||
@@ -2863,13 +2877,13 @@ export function LaneChangesPanel({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="lane-changes-discard-all"
|
data-testid="lane-changes-discard-all"
|
||||||
disabled={busy}
|
disabled={busy || tickedPaths.length === 0}
|
||||||
onClick={() => void discardAll()}
|
onClick={() => void discardTicked()}
|
||||||
title={t("changes.discardAllTitle")}
|
title={t("changes.discardTickedTitle")}
|
||||||
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"
|
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 />
|
<Trash2 className="h-3 w-3" aria-hidden />
|
||||||
{t("changes.discardAll")}
|
{t("changes.discardTicked", { count: tickedPaths.length })}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -179,6 +179,18 @@ export function langFor(filePath: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Tokenized line text. `emph` marks the span that actually changed. */
|
/** Tokenized line text. `emph` marks the span that actually changed. */
|
||||||
|
/** The tail of a row the server cut down (`LaneDiffLine.elided`). */
|
||||||
|
function Elided({ n }: { n?: number }) {
|
||||||
|
const { t, i18n } = useTranslation("lanes");
|
||||||
|
if (!n) return null;
|
||||||
|
const size = new Intl.NumberFormat(i18n.language, { notation: "compact" }).format(n);
|
||||||
|
return (
|
||||||
|
<span className="ml-1 select-none rounded bg-surface-3 px-1 font-sans text-[10px] text-fg-muted">
|
||||||
|
{t("changes.lineElided", { size })}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) {
|
function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) {
|
||||||
if (emph) {
|
if (emph) {
|
||||||
const [from, to] = emph;
|
const [from, to] = emph;
|
||||||
@@ -544,6 +556,7 @@ function UnifiedLine({
|
|||||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||||
</span>
|
</span>
|
||||||
<Code text={line.text} lang={lang} emph={emph} />
|
<Code text={line.text} lang={lang} emph={emph} />
|
||||||
|
<Elided n={line.elided} />
|
||||||
</span>
|
</span>
|
||||||
{bar && <HunkBar change={bar} />}
|
{bar && <HunkBar change={bar} />}
|
||||||
</Row>
|
</Row>
|
||||||
@@ -621,6 +634,7 @@ function SplitLine({
|
|||||||
/>
|
/>
|
||||||
<span className="whitespace-pre px-1">
|
<span className="whitespace-pre px-1">
|
||||||
{line ? <Code text={line.text} lang={lang} emph={e} /> : null}
|
{line ? <Code text={line.text} lang={lang} emph={e} /> : null}
|
||||||
|
{line ? <Elided n={line.elided} /> : null}
|
||||||
</span>
|
</span>
|
||||||
{bar && <HunkBar change={bar} />}
|
{bar && <HunkBar change={bar} />}
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
|
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
|
||||||
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
||||||
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
||||||
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
|
* its right-click menu — Archive on a live session stops its pane first, `endRuns`), then the archived ones folded with Unarchive; a live session opens its pane,
|
||||||
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
||||||
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
|
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
|
import { endRuns } from "../../lib/stopSession";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@@ -307,30 +308,30 @@ function HomeBody({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const archiveSession = async (s: Session) => {
|
const archiveSession = async (s: Session) => {
|
||||||
const live = !!liveRunFor(lane, liveRuns, s.id);
|
const run = liveRunFor(lane, liveRuns, s.id);
|
||||||
|
// A live one is stopped first, as the tab's X does (its own confirm):
|
||||||
|
// archiving only its row left a pane running that no list showed.
|
||||||
|
if (run) {
|
||||||
|
if ((await endRuns([run], { name: s.name || t("tree.newSession"), t })).length)
|
||||||
|
await patchSession(s, { archived: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const wt = sessionWorktreeName(lane, s);
|
const wt = sessionWorktreeName(lane, s);
|
||||||
const ok = await confirmDialog(
|
const ok = await confirmDialog(
|
||||||
wt && !live
|
wt
|
||||||
? {
|
? {
|
||||||
title: t("tree.archiveWorktreeTitle"),
|
title: t("tree.archiveWorktreeTitle"),
|
||||||
message: t("tree.archiveWorktreeBody", { name: wt }),
|
message: t("tree.archiveWorktreeBody", { name: wt }),
|
||||||
confirmLabel: t("tree.archiveWorktreeConfirm"),
|
confirmLabel: t("tree.archiveWorktreeConfirm"),
|
||||||
}
|
}
|
||||||
: live
|
: {
|
||||||
? {
|
title: t("tree.archiveSession"),
|
||||||
title: t("tree.archiveLiveTitle"),
|
confirmLabel: t("sessionMenu.archive"),
|
||||||
message: t("tree.archiveLiveBody"),
|
destructive: false,
|
||||||
confirmLabel: t("sessionMenu.archive"),
|
}
|
||||||
destructive: false,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
title: t("tree.archiveSession"),
|
|
||||||
confirmLabel: t("sessionMenu.archive"),
|
|
||||||
destructive: false,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
if (wt && !live) {
|
if (wt) {
|
||||||
try {
|
try {
|
||||||
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
|
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
|
||||||
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
|
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
|
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
|
||||||
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
|
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
|
||||||
* live tab whose session was archived is not brought back as a New session,
|
* live tab whose session was archived is not brought back as a New session,
|
||||||
* and archiving a live one asks first. F2 renames the focused row too; each
|
* and archiving a live one STOPS it first (`endRuns`, the tab X's confirm). F2 renames the focused row too; each
|
||||||
* dot carries its meaning as screen-reader text. `readOnly` (another user's
|
* dot carries its meaning as screen-reader text. `readOnly` (another user's
|
||||||
* workspace, for an analyst): no live tabs, no rename/archive/menu, and a row
|
* workspace, for an analyst): no live tabs, no rename/archive/menu, and a row
|
||||||
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
|
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { endRuns } from "../../lib/stopSession";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
|
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -363,17 +363,10 @@ export function WorkspaceSessionList({
|
|||||||
};
|
};
|
||||||
const archive = async (s: Session) => {
|
const archive = async (s: Session) => {
|
||||||
if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s);
|
if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s);
|
||||||
// A live one keeps running in its tab; archiving only hides its row.
|
// A live one is stopped first, as the tab's X does: archiving only its row
|
||||||
if (
|
// left a pane running that no list showed any more.
|
||||||
runFor(s) &&
|
const run = runFor(s);
|
||||||
!(await confirmDialog({
|
if (run && !(await endRuns([run], { name: s.name || t("tree.newSession"), t })).length) return;
|
||||||
title: t("tree.archiveLiveTitle"),
|
|
||||||
message: t("tree.archiveLiveBody"),
|
|
||||||
confirmLabel: t("sessionMenu.archive"),
|
|
||||||
destructive: false,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
await patch(s, { archived: true });
|
await patch(s, { archived: true });
|
||||||
};
|
};
|
||||||
const archiveWithWorktree = async (s: Session) => {
|
const archiveWithWorktree = async (s: Session) => {
|
||||||
|
|||||||
@@ -837,10 +837,12 @@ describe("LaneChangesPanel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("discards every file at once only after the dialog is confirmed", async () => {
|
it("discards only the ticked files, and only after the dialog is confirmed (#87)", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
await screen.findByTestId("lane-changes-overview");
|
await screen.findByTestId("lane-changes-overview");
|
||||||
|
// notes.txt (unversioned) starts unticked; untick docs/API.md too.
|
||||||
|
await user.click(tickOf("docs/API.md"));
|
||||||
|
|
||||||
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||||
@@ -849,10 +851,14 @@ describe("LaneChangesPanel", () => {
|
|||||||
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
||||||
paths: ["server/lib/git-ops.js", "notes.txt", "docs/API.md"],
|
paths: ["server/lib/git-ops.js"],
|
||||||
confirm: true,
|
confirm: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Nothing ticked: nothing to discard.
|
||||||
|
await user.click(tickOf("server/lib/git-ops.js"));
|
||||||
|
expect(screen.getByTestId("lane-changes-discard-all")).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
|
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
|
||||||
@@ -1673,6 +1679,25 @@ describe("LaneChangesPanel", () => {
|
|||||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("discards only the ticked change blocks of a partly ticked file (#87)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await openReview(user);
|
||||||
|
await user.click(
|
||||||
|
screen.getAllByRole("checkbox", { name: "Include this change in the commit" })[1]!
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Changes" }));
|
||||||
|
await screen.findByTestId("lane-changes-commit");
|
||||||
|
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
||||||
|
paths: [],
|
||||||
|
hunks: { "server/lib/git-ops.js": [{ hunk: "@@ -1,2 +1,3 @@", blocks: [0] }] },
|
||||||
|
context: 3,
|
||||||
|
confirm: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("commits only the ticked change blocks of a partly ticked file (hunks)", async () => {
|
it("commits only the ticked change blocks of a partly ticked file (hunks)", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
await openReview(user);
|
await openReview(user);
|
||||||
|
|||||||
@@ -609,3 +609,26 @@ describe("LaneDiffView — line comments", () => {
|
|||||||
expect(screen.queryByTestId("diff-comment-line-1")).toBeNull();
|
expect(screen.queryByTestId("diff-comment-line-1")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("LaneDiffView — a row the server cut down", () => {
|
||||||
|
const CUT: LaneChangeFile = {
|
||||||
|
...FILE,
|
||||||
|
hunks: [
|
||||||
|
{
|
||||||
|
...FILE.hunks![0]!,
|
||||||
|
lines: [
|
||||||
|
{ type: "del", oldLine: 1, newLine: null, text: '<video src="AAAA', elided: 3_000_000 },
|
||||||
|
{ type: "add", oldLine: null, newLine: 1, text: '<video src="BBBB', elided: 3_000_000 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("says how much of the row it hides, in both layouts", () => {
|
||||||
|
const { unmount } = render(<LaneDiffView file={CUT} layout="unified" />);
|
||||||
|
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
|
||||||
|
unmount();
|
||||||
|
render(<LaneDiffView file={CUT} layout="split" />);
|
||||||
|
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -96,6 +96,36 @@ type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigL
|
|||||||
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||||
/** localStorage flag: the "Shift+drag selects text" hint was dismissed. */
|
/** localStorage flag: the "Shift+drag selects text" hint was dismissed. */
|
||||||
|
/** The atlas a WebGL addon draws from: @xterm/addon-webgl 0.19 keeps these
|
||||||
|
* fields unminified in its build. Undefined on any other shape. */
|
||||||
|
type AtlasPages = { pages: { version: number }[] };
|
||||||
|
const atlasOf = (addon: WebglAddon): AtlasPages | undefined => {
|
||||||
|
const atlas = (addon as unknown as { _renderer?: { _charAtlas?: AtlasPages } })._renderer
|
||||||
|
?._charAtlas;
|
||||||
|
return Array.isArray(atlas?.pages) ? atlas : undefined;
|
||||||
|
};
|
||||||
|
/** Last version handed out by `restampAtlas`, shared by every pane. */
|
||||||
|
let atlasStamp = 0;
|
||||||
|
/**
|
||||||
|
* Gives every page of the addon's glyph atlas a version no texture slot has
|
||||||
|
* uploaded, so EVERY renderer sharing that atlas re-uploads every page from
|
||||||
|
* its canvas on its next draw — what addon-webgl 0.20 does with globally
|
||||||
|
* monotonic page versions. 0.19 counts per page, so after a merge shifts the
|
||||||
|
* pages down a slot can hold a stale page whose counter happens to match, and
|
||||||
|
* glyphs are sampled squeezed or foreign. Steps of 2^24 stay clear of the
|
||||||
|
* per-glyph increments a page makes before the next restamp. False when the
|
||||||
|
* addon is not the shape this knows.
|
||||||
|
*/
|
||||||
|
const restampAtlas = (addon: WebglAddon): boolean => {
|
||||||
|
const atlas = atlasOf(addon);
|
||||||
|
if (!atlas) return false;
|
||||||
|
for (const page of atlas.pages) {
|
||||||
|
atlasStamp += 2 ** 24;
|
||||||
|
page.version = atlasStamp;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const SELECT_HINT_KEY = "agenthub.terminal.selectHintSeen";
|
const SELECT_HINT_KEY = "agenthub.terminal.selectHintSeen";
|
||||||
const readFlag = (key: string) => {
|
const readFlag = (key: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -468,7 +498,8 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
// A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`):
|
// A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`):
|
||||||
// fitting it there shrinks xterm to its 12×6 minimum, and that size would
|
// fitting it there shrinks xterm to its 12×6 minimum, and that size would
|
||||||
// reach tmux with the attach. It stays unfitted until it is shown.
|
// reach tmux with the attach. It stays unfitted until it is shown.
|
||||||
const hasBox = () => !!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
|
const hasBox = () =>
|
||||||
|
!!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
|
||||||
if (hasBox()) fit.fit();
|
if (hasBox()) fit.fit();
|
||||||
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
||||||
// page here, and having to click it first meant the first thing typed went
|
// page here, and having to click it first meant the first thing typed went
|
||||||
@@ -761,6 +792,44 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
refit();
|
refit();
|
||||||
};
|
};
|
||||||
document.fonts?.addEventListener("loadingdone", onFontLoaded);
|
document.fonts?.addEventListener("loadingdone", onFontLoaded);
|
||||||
|
// @xterm/addon-webgl 0.19 shares ONE glyph atlas between every terminal
|
||||||
|
// with the same font. When it fills up it merges four pages into one
|
||||||
|
// larger page and shifts the rest down, and a GPU texture is re-uploaded
|
||||||
|
// only when its page's `version` differs from the uploaded one — after a
|
||||||
|
// shift those per-page counters can coincide, so a slot keeps a stale
|
||||||
|
// page of another size and every glyph is drawn squeezed or foreign
|
||||||
|
// while the buffer stays correct (fixed upstream only in 0.20 betas).
|
||||||
|
// Any change to the page list therefore restamps every page's version
|
||||||
|
// (`restampAtlas`) synchronously, before the next draw of ANY pane on
|
||||||
|
// that atlas. `clearTextureAtlas` is no fix: it empties the SHARED atlas
|
||||||
|
// but redraws only this pane, leaving its siblings slicing reused space.
|
||||||
|
// An addon of another shape falls back to toggling the family's
|
||||||
|
// trailing space on the next frame, which makes each renderer re-acquire
|
||||||
|
// its atlas (as onFontLoaded does).
|
||||||
|
let atlasFrame = 0;
|
||||||
|
const rebuildAtlas = () => {
|
||||||
|
if (gpu && restampAtlas(gpu)) return true;
|
||||||
|
if (atlasFrame) return false;
|
||||||
|
// Never inside the render that is rasterizing the merging glyph.
|
||||||
|
atlasFrame = requestAnimationFrame(() => {
|
||||||
|
atlasFrame = 0;
|
||||||
|
const family = term.options.fontFamily ?? "";
|
||||||
|
term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `;
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const atlasMerged = gpu?.onRemoveTextureAtlasCanvas(rebuildAtlas);
|
||||||
|
const atlasGrew = gpu?.onAddTextureAtlasCanvas(rebuildAtlas);
|
||||||
|
// The browser may also drop the textures without a context-loss event
|
||||||
|
// (sleep/resume, a long-backgrounded tab): re-upload when the page is
|
||||||
|
// back, and repaint, since nothing else asks this pane to draw.
|
||||||
|
const onPageShown = () => {
|
||||||
|
if (document.visibilityState === "visible" && rebuildAtlas()) {
|
||||||
|
term.refresh(0, term.rows - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onPageShown);
|
||||||
|
window.addEventListener("focus", onPageShown);
|
||||||
// Settings → Appearance changed the terminal's font or size: an open
|
// Settings → Appearance changed the terminal's font or size: an open
|
||||||
// pane takes it now, re-measured and re-fitted like a font landing.
|
// pane takes it now, re-measured and re-fitted like a font landing.
|
||||||
const onFontPref = () => {
|
const onFontPref = () => {
|
||||||
@@ -779,6 +848,11 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
searchRef.current = null;
|
searchRef.current = null;
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
||||||
|
if (atlasFrame) cancelAnimationFrame(atlasFrame);
|
||||||
|
atlasMerged?.dispose();
|
||||||
|
atlasGrew?.dispose();
|
||||||
|
document.removeEventListener("visibilitychange", onPageShown);
|
||||||
|
window.removeEventListener("focus", onPageShown);
|
||||||
window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
|
window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
|
||||||
term.textarea?.removeEventListener("focus", reassert);
|
term.textarea?.removeEventListener("focus", reassert);
|
||||||
detachIme();
|
detachIme();
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ const termState = {
|
|||||||
};
|
};
|
||||||
const onResizeHandlers: Array<() => void> = [];
|
const onResizeHandlers: Array<() => void> = [];
|
||||||
const termOptions: { theme?: unknown } = {};
|
const termOptions: { theme?: unknown } = {};
|
||||||
|
const refreshMock = vi.fn();
|
||||||
|
/** The atlas the next WebGL addon draws from; null = an addon of another shape. */
|
||||||
|
const nextAtlas: { pages: { version: number }[] | null } = { pages: null };
|
||||||
const ctorArgs: Array<{ theme?: unknown }> = [];
|
const ctorArgs: Array<{ theme?: unknown }> = [];
|
||||||
|
|
||||||
vi.mock("@xterm/xterm", () => ({
|
vi.mock("@xterm/xterm", () => ({
|
||||||
@@ -92,6 +95,7 @@ vi.mock("@xterm/xterm", () => ({
|
|||||||
return { dispose: vi.fn() };
|
return { dispose: vi.fn() };
|
||||||
},
|
},
|
||||||
dispose: disposeMock,
|
dispose: disposeMock,
|
||||||
|
refresh: refreshMock,
|
||||||
loadAddon: loadAddonMock,
|
loadAddon: loadAddonMock,
|
||||||
attachCustomWheelEventHandler: wheelHandlerMock,
|
attachCustomWheelEventHandler: wheelHandlerMock,
|
||||||
attachCustomKeyEventHandler: keyHandlerMock,
|
attachCustomKeyEventHandler: keyHandlerMock,
|
||||||
@@ -112,21 +116,44 @@ vi.mock("@xterm/addon-fit", () => ({
|
|||||||
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
||||||
}));
|
}));
|
||||||
// The newest WebGL addon, with the context-loss listener the view gave it.
|
// The newest WebGL addon, with the context-loss listener the view gave it.
|
||||||
const gpu: { last: { dispose: ReturnType<typeof vi.fn>; lose: () => void } | null } = {
|
const gpu: {
|
||||||
|
last: {
|
||||||
|
dispose: ReturnType<typeof vi.fn>;
|
||||||
|
lose: () => void;
|
||||||
|
merge: () => void;
|
||||||
|
grow: () => void;
|
||||||
|
} | null;
|
||||||
|
} = {
|
||||||
last: null,
|
last: null,
|
||||||
};
|
};
|
||||||
vi.mock("@xterm/addon-webgl", () => ({
|
vi.mock("@xterm/addon-webgl", () => ({
|
||||||
WebglAddon: vi.fn().mockImplementation(() => {
|
WebglAddon: vi.fn().mockImplementation(() => {
|
||||||
let onLoss = () => {};
|
let onLoss = () => {};
|
||||||
|
let onMerge = () => {};
|
||||||
|
let onGrow = () => {};
|
||||||
const addon = {
|
const addon = {
|
||||||
__webgl: true,
|
__webgl: true,
|
||||||
|
...(nextAtlas.pages ? { _renderer: { _charAtlas: { pages: nextAtlas.pages } } } : {}),
|
||||||
dispose: vi.fn(),
|
dispose: vi.fn(),
|
||||||
onContextLoss: (fn: () => void) => {
|
onContextLoss: (fn: () => void) => {
|
||||||
onLoss = fn;
|
onLoss = fn;
|
||||||
return { dispose: vi.fn() };
|
return { dispose: vi.fn() };
|
||||||
},
|
},
|
||||||
|
onRemoveTextureAtlasCanvas: (fn: () => void) => {
|
||||||
|
onMerge = fn;
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
},
|
||||||
|
onAddTextureAtlasCanvas: (fn: () => void) => {
|
||||||
|
onGrow = fn;
|
||||||
|
return { dispose: vi.fn() };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
gpu.last = {
|
||||||
|
dispose: addon.dispose,
|
||||||
|
lose: () => onLoss(),
|
||||||
|
merge: () => onMerge(),
|
||||||
|
grow: () => onGrow(),
|
||||||
};
|
};
|
||||||
gpu.last = { dispose: addon.dispose, lose: () => onLoss() };
|
|
||||||
return addon;
|
return addon;
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -488,6 +515,50 @@ describe("TerminalView", () => {
|
|||||||
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
|
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restamps every atlas page at once when the page list changes", () => {
|
||||||
|
// addon-webgl 0.19 counts versions per page, so after a merge shifts the
|
||||||
|
// pages a texture slot can keep a stale page whose counter matches. Every
|
||||||
|
// page gets a version no slot has uploaded — in every pane on the atlas.
|
||||||
|
const pages = [{ version: 3 }, { version: 7 }, { version: 1 }];
|
||||||
|
nextAtlas.pages = pages;
|
||||||
|
const raf = vi.spyOn(window, "requestAnimationFrame");
|
||||||
|
try {
|
||||||
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
gpu.last!.merge();
|
||||||
|
const first = pages.map((p) => p.version);
|
||||||
|
expect(new Set(first).size).toBe(3);
|
||||||
|
expect(first.every((v) => v > 7)).toBe(true);
|
||||||
|
gpu.last!.grow();
|
||||||
|
expect(pages.every((p, i) => p.version > Math.max(...first) && p.version !== first[i])).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(raf).not.toHaveBeenCalled(); // synchronous, before any pane draws
|
||||||
|
// The page coming back re-uploads and repaints this pane.
|
||||||
|
refreshMock.mockClear();
|
||||||
|
window.dispatchEvent(new Event("focus"));
|
||||||
|
expect(refreshMock).toHaveBeenCalledWith(0, 27);
|
||||||
|
} finally {
|
||||||
|
nextAtlas.pages = null;
|
||||||
|
raf.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-acquires the glyph atlas on the next frame after a page merge", () => {
|
||||||
|
// An addon whose atlas this cannot reach: any option change makes each
|
||||||
|
// renderer re-acquire its atlas and re-upload every page.
|
||||||
|
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1);
|
||||||
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
const opts = termOptions as { fontFamily?: string };
|
||||||
|
opts.fontFamily = "JetBrains Mono";
|
||||||
|
gpu.last!.merge();
|
||||||
|
gpu.last!.merge(); // coalesced into one frame
|
||||||
|
expect(raf).toHaveBeenCalledTimes(1);
|
||||||
|
expect(opts.fontFamily).toBe("JetBrains Mono"); // never inside the render that merged
|
||||||
|
(raf.mock.calls[0]![0] as FrameRequestCallback)(0);
|
||||||
|
expect(opts.fontFamily).toBe("JetBrains Mono ");
|
||||||
|
raf.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
|
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
|
||||||
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
|
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
|
||||||
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
|
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
|
||||||
|
|||||||
@@ -262,11 +262,11 @@ function LabelEdit({
|
|||||||
function useRunner(load: () => Promise<unknown>) {
|
function useRunner(load: () => Promise<unknown>) {
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const run = useCallback(
|
const run = useCallback(
|
||||||
async (key: string, fn: () => Promise<unknown>, success: string) => {
|
async <T,>(key: string, fn: () => Promise<T>, success: string | ((r: T) => string)) => {
|
||||||
setBusy(key);
|
setBusy(key);
|
||||||
try {
|
try {
|
||||||
await fn();
|
const r = await fn();
|
||||||
toast.success(success);
|
toast.success(typeof success === "function" ? success(r) : success);
|
||||||
await load();
|
await load();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(err);
|
toast.error(err);
|
||||||
@@ -399,7 +399,14 @@ function OtherProfiles({
|
|||||||
void run(
|
void run(
|
||||||
`switch:${p.name}`,
|
`switch:${p.name}`,
|
||||||
() => api.toolProfiles.activate(tool, p.name),
|
() => api.toolProfiles.activate(tool, p.name),
|
||||||
t("toolProfiles.switchSuccess", { name: p.label, tool: label })
|
(r) =>
|
||||||
|
t(
|
||||||
|
r.following ? "toolProfiles.switchFollowing" : "toolProfiles.switchSuccess",
|
||||||
|
{
|
||||||
|
name: p.label,
|
||||||
|
tool: label,
|
||||||
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className={btnCls}
|
className={btnCls}
|
||||||
@@ -800,9 +807,7 @@ function ProfileUsageLine({
|
|||||||
|
|
||||||
/** Org / seat / plan line shared by the active card and each profile row. */
|
/** Org / seat / plan line shared by the active card and each profile row. */
|
||||||
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
||||||
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(
|
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(Boolean);
|
||||||
Boolean
|
|
||||||
);
|
|
||||||
if (parts.length === 0) return null;
|
if (parts.length === 0) return null;
|
||||||
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
|
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import type { AuthUser, ServerUserUsage } from "../../lib/api";
|
import type { AuthUser, ServerUserUsage } from "../../lib/api";
|
||||||
import { Checkbox } from "../Checkbox";
|
import { Checkbox } from "../Checkbox";
|
||||||
|
import { UserAvatar } from "./UserAvatar";
|
||||||
|
|
||||||
export const ROLES: AuthUser["role"][] = ["admin", "developer", "analyst"];
|
export const ROLES: AuthUser["role"][] = ["admin", "developer", "analyst"];
|
||||||
/** A role, every user, or the users in no pool group. */
|
/** A role, every user, or the users in no pool group. */
|
||||||
@@ -168,7 +169,14 @@ export function MemberPicker({
|
|||||||
checked={chosen.has(u.id)}
|
checked={chosen.has(u.id)}
|
||||||
onChange={(on) => setMany([u.id], on)}
|
onChange={(on) => setMany([u.id], on)}
|
||||||
label={
|
label={
|
||||||
<span>
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<UserAvatar
|
||||||
|
name={u.displayName || u.username}
|
||||||
|
userId={u.id}
|
||||||
|
avatarAt={u.avatarAt}
|
||||||
|
letters={1}
|
||||||
|
className="h-5 w-5 text-[10px]"
|
||||||
|
/>
|
||||||
{u.username}
|
{u.username}
|
||||||
{u.disabled && <span className="ml-1 text-fg-muted">({t("disabled")})</span>}
|
{u.disabled && <span className="ml-1 text-fg-muted">({t("disabled")})</span>}
|
||||||
{note?.(u)}
|
{note?.(u)}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { toast } from "../../lib/toast";
|
|||||||
import { ConfirmModal } from "../ConfirmModal";
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
import { AiToolIcon } from "../AiToolIcon";
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
import { MemberPicker } from "./MemberPicker";
|
import { MemberPicker } from "./MemberPicker";
|
||||||
|
import { UserAvatar } from "./UserAvatar";
|
||||||
|
|
||||||
/** Pool tools with their labels, in display order. */
|
/** Pool tools with their labels, in display order. */
|
||||||
export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [
|
export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [
|
||||||
@@ -363,12 +364,13 @@ export function PoolGroups({
|
|||||||
} ${isDragged(drag) ? "opacity-40" : ""}`}
|
} ${isDragged(drag) ? "opacity-40" : ""}`}
|
||||||
data-testid="pool-member"
|
data-testid="pool-member"
|
||||||
>
|
>
|
||||||
<span
|
<UserAvatar
|
||||||
className="flex h-4 w-4 items-center justify-center rounded-full bg-accent/15 text-[9px] font-semibold uppercase text-accent"
|
name={name}
|
||||||
aria-hidden
|
userId={id}
|
||||||
>
|
avatarAt={u?.avatarAt}
|
||||||
{name.charAt(0)}
|
letters={1}
|
||||||
</span>
|
className="h-4 w-4 text-[9px]"
|
||||||
|
/>
|
||||||
{name}
|
{name}
|
||||||
{u && <span className="text-[10px] text-fg-muted">{t(`roles.${u.role}`)}</span>}
|
{u && <span className="text-[10px] text-fg-muted">{t(`roles.${u.role}`)}</span>}
|
||||||
{isAdmin && from && (
|
{isAdmin && from && (
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET
|
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET
|
||||||
* /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one —
|
* /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one —
|
||||||
* or when the image fails to load — the first letters of its name. Used by the
|
* or when the image fails to load — the first letters of its name. Used by the
|
||||||
* sidebar's account row, its workspace owner groups and Settings → Account.
|
* sidebar's account row, its workspace owner groups, Settings → Account, the
|
||||||
|
* Users page and its editor, the pool board and member picker, and System
|
||||||
|
* Health's per-user CPU/RAM card.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
* @file UserEditDialog.tsx
|
* @file UserEditDialog.tsx
|
||||||
* @description The Users page's ONE account editor (admin only): profile
|
* @description The Users page's ONE account editor (admin only): profile
|
||||||
* (display name, email), access (role, active/disabled — neither for your own
|
* (display name, email), access (role, active/disabled — neither for your own
|
||||||
* account), a new password (typed or generated, copyable — someone else's
|
* account — and, for an admin, whether they own the host's Linux account, so
|
||||||
|
* its sessions outside every workspace are billed to them), 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
|
* 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
|
* must pass every rule of `lib/passwordRules.ts`), the workspaces the
|
||||||
* account owns (remove, add from every workspace), its spend quota (the
|
* account owns (remove, add from every workspace), its spend quota (the
|
||||||
@@ -33,6 +34,7 @@ import { copyText } from "../../lib/clipboard";
|
|||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { passwordProblems } from "../../lib/passwordRules";
|
import { passwordProblems } from "../../lib/passwordRules";
|
||||||
import { PasswordRules } from "./PasswordRules";
|
import { PasswordRules } from "./PasswordRules";
|
||||||
|
import { UserAvatar } from "./UserAvatar";
|
||||||
import { useDialog } from "../../hooks/useDialog";
|
import { useDialog } from "../../hooks/useDialog";
|
||||||
import { Switch } from "../Switch";
|
import { Switch } from "../Switch";
|
||||||
import { Checkbox } from "../Checkbox";
|
import { Checkbox } from "../Checkbox";
|
||||||
@@ -86,6 +88,7 @@ export function UserEditDialog({
|
|||||||
const [email, setEmail] = useState(user.email ?? "");
|
const [email, setEmail] = useState(user.email ?? "");
|
||||||
const [role, setRole] = useState<Role>(user.role);
|
const [role, setRole] = useState<Role>(user.role);
|
||||||
const [active, setActive] = useState(!user.disabled);
|
const [active, setActive] = useState(!user.disabled);
|
||||||
|
const [hostAccount, setHostAccount] = useState(Boolean(user.hostAccount));
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [owned, setOwned] = useState<OwnedWorkspace[] | null>(null);
|
const [owned, setOwned] = useState<OwnedWorkspace[] | null>(null);
|
||||||
@@ -138,6 +141,7 @@ export function UserEditDialog({
|
|||||||
if (norm(email) !== (user.email ?? null)) patch.email = norm(email);
|
if (norm(email) !== (user.email ?? null)) patch.email = norm(email);
|
||||||
if (!self && role !== user.role) patch.role = role;
|
if (!self && role !== user.role) patch.role = role;
|
||||||
if (!self && active === user.disabled) patch.disabled = !active;
|
if (!self && active === user.disabled) patch.disabled = !active;
|
||||||
|
if (role === "admin" && hostAccount !== Boolean(user.hostAccount)) patch.hostAccount = hostAccount;
|
||||||
if (password) patch.password = password;
|
if (password) patch.password = password;
|
||||||
if (added.length) patch.addWorkspaces = added;
|
if (added.length) patch.addWorkspaces = added;
|
||||||
if (removed.size) patch.removeWorkspaces = [...removed];
|
if (removed.size) patch.removeWorkspaces = [...removed];
|
||||||
@@ -243,12 +247,12 @@ export function UserEditDialog({
|
|||||||
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
|
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
|
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
|
||||||
<span
|
<UserAvatar
|
||||||
aria-hidden
|
name={shown}
|
||||||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-accent/15 text-sm font-semibold uppercase text-accent"
|
userId={user.id}
|
||||||
>
|
avatarAt={user.avatarAt}
|
||||||
{shown.slice(0, 2)}
|
className="h-10 w-10 text-sm"
|
||||||
</span>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 id="user-edit-title" className="text-base font-semibold text-fg-primary">
|
<h2 id="user-edit-title" className="text-base font-semibold text-fg-primary">
|
||||||
{t("edit.title", { name: user.displayName || user.username })}
|
{t("edit.title", { name: user.displayName || user.username })}
|
||||||
@@ -363,6 +367,17 @@ export function UserEditDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{role === "admin" && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={hostAccount}
|
||||||
|
onChange={setHostAccount}
|
||||||
|
label={t("edit.hostAccount")}
|
||||||
|
labelClassName="text-sm text-fg-secondary"
|
||||||
|
/>
|
||||||
|
<p className="ml-6 mt-0.5 text-[11px] text-fg-muted">{t("edit.hostAccountHint")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title={t("edit.password")}>
|
<Section title={t("edit.password")}>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const accountPool = vi.hoisted(() => ({
|
|||||||
updateGroup: vi.fn(),
|
updateGroup: vi.fn(),
|
||||||
deleteGroup: vi.fn(),
|
deleteGroup: vi.fn(),
|
||||||
}));
|
}));
|
||||||
const users = vi.hoisted(() => ({ list: vi.fn() }));
|
const users = vi.hoisted(() => ({ list: vi.fn(), avatarUrl: () => null }));
|
||||||
const aiTools = vi.hoisted(() => ({
|
const aiTools = vi.hoisted(() => ({
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
ompSharing: vi.fn(),
|
ompSharing: vi.fn(),
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ import { render, screen, waitFor } from "@testing-library/react";
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import type { ServerUserUsage } from "../../../lib/api";
|
import type { ServerUserUsage } from "../../../lib/api";
|
||||||
|
|
||||||
const users = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn(), remove: vi.fn() }));
|
const users = vi.hoisted(() => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
avatarUrl: () => null,
|
||||||
|
}));
|
||||||
const confirmDialog = vi.hoisted(() => vi.fn());
|
const confirmDialog = vi.hoisted(() => vi.fn());
|
||||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
|
vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
|
||||||
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
|
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const users = vi.hoisted(() => ({
|
|||||||
setQuotaDefault: vi.fn(),
|
setQuotaDefault: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
|
avatarUrl: () => null,
|
||||||
}));
|
}));
|
||||||
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
|
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
|
||||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
|
|||||||
@@ -28,6 +28,12 @@
|
|||||||
"unshare": "Unshare",
|
"unshare": "Unshare",
|
||||||
"shareDone": "Shared to the Hub",
|
"shareDone": "Shared to the Hub",
|
||||||
"unshareDone": "Removed from the Hub",
|
"unshareDone": "Removed from the Hub",
|
||||||
|
"reshare": "Share again",
|
||||||
|
"reshareDone": "New version shared — people who installed it are offered Update",
|
||||||
|
"reshareSame": "Already the newest version — shared",
|
||||||
|
"reshareAskTitle": "Choose the {{name}} folder again",
|
||||||
|
"reshareAskBody": "This server has no copy of {{name}}. Choose the skill's folder (the one holding SKILL.md): it replaces this version, and people who installed it are offered Update. For a .zip, upload it through Add skill → Upload: a skill with the same name updates your existing one.",
|
||||||
|
"resharePick": "Choose folder",
|
||||||
"open": "Open",
|
"open": "Open",
|
||||||
"copyLink": "Copy link",
|
"copyLink": "Copy link",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
@@ -136,6 +142,11 @@
|
|||||||
"installsYou": "You",
|
"installsYou": "You",
|
||||||
"installsUnknown": "A removed user",
|
"installsUnknown": "A removed user",
|
||||||
"installsReplaced": "replaced an older copy",
|
"installsReplaced": "replaced an older copy",
|
||||||
|
"versionAt": "Updated {{ago}}",
|
||||||
|
"versionAtFull": "Last updated {{date}}",
|
||||||
|
"installState.latest": "Latest",
|
||||||
|
"installState.outdated": "Old version",
|
||||||
|
"installState.removed": "Removed",
|
||||||
"installsPluginWhere": "claude plugin install --scope user",
|
"installsPluginWhere": "claude plugin install --scope user",
|
||||||
"installsUnrecorded": "{{count}} earlier installs were counted before details were kept",
|
"installsUnrecorded": "{{count}} earlier installs were counted before details were kept",
|
||||||
"installsNoneOfYours": "You have not installed it — only your own installs are listed.",
|
"installsNoneOfYours": "You have not installed it — only your own installs are listed.",
|
||||||
@@ -179,7 +190,7 @@
|
|||||||
"stat.installs": "Installs",
|
"stat.installs": "Installs",
|
||||||
"stat.files": "Files",
|
"stat.files": "Files",
|
||||||
"stat.size": "Size",
|
"stat.size": "Size",
|
||||||
"stat.updated": "Updated",
|
"stat.updated": "Last updated",
|
||||||
"detail.runAs": "Run it as",
|
"detail.runAs": "Run it as",
|
||||||
"detail.market": "Marketplace",
|
"detail.market": "Marketplace",
|
||||||
"detail.added": "Added {{date}}",
|
"detail.added": "Added {{date}}",
|
||||||
|
|||||||
@@ -92,6 +92,7 @@
|
|||||||
"changes.expandFile": "Expand {{path}}",
|
"changes.expandFile": "Expand {{path}}",
|
||||||
"changes.fileGone": "That file no longer has changes in this range.",
|
"changes.fileGone": "That file no longer has changes in this range.",
|
||||||
"changes.fileTruncated": "Diff cut short — open the file in an editor for the rest.",
|
"changes.fileTruncated": "Diff cut short — open the file in an editor for the rest.",
|
||||||
|
"changes.lineElided": "… {{size}} more characters on this line",
|
||||||
"changes.commentLine": "Comment on line {{n}} — pasted into this workspace's pane",
|
"changes.commentLine": "Comment on line {{n}} — pasted into this workspace's pane",
|
||||||
"changes.commentOn": "Comment on {{at}} — pasted into the pane you used last; nothing is sent until you press Enter there",
|
"changes.commentOn": "Comment on {{at}} — pasted into the pane you used last; nothing is sent until you press Enter there",
|
||||||
"changes.commentSend": "Paste into pane",
|
"changes.commentSend": "Paste into pane",
|
||||||
@@ -505,8 +506,6 @@
|
|||||||
"changes.loadMore": "Load more commits",
|
"changes.loadMore": "Load more commits",
|
||||||
"changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it",
|
"changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it",
|
||||||
"changes.splitter": "Resize the changes panel",
|
"changes.splitter": "Resize the changes panel",
|
||||||
"tree.archiveLiveTitle": "Archive a live session?",
|
|
||||||
"tree.archiveLiveBody": "Its tab keeps running; archiving only hides it from this list. Unarchive it from the archived fold.",
|
|
||||||
"tree.state.awaiting": "waiting for you",
|
"tree.state.awaiting": "waiting for you",
|
||||||
"tree.state.error": "error",
|
"tree.state.error": "error",
|
||||||
"tree.state.live": "live",
|
"tree.state.live": "live",
|
||||||
@@ -533,9 +532,9 @@
|
|||||||
"changes.unversionedGroup": "Unversioned Files",
|
"changes.unversionedGroup": "Unversioned Files",
|
||||||
"changes.commitTicked": "Commit ({{count}})",
|
"changes.commitTicked": "Commit ({{count}})",
|
||||||
"changes.commitAndPush": "Commit and Push",
|
"changes.commitAndPush": "Commit and Push",
|
||||||
"changes.discardAll": "Discard all",
|
"changes.discardTicked": "Discard ({{count}})",
|
||||||
"changes.discardAllTitle": "Discard all changes?",
|
"changes.discardTickedTitle": "Discard the ticked 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.discardTickedConfirm": "The ticked changes in {{count}} file(s) go back to the last commit, and {{untracked}} ticked new file(s) git does not track yet are deleted. Unticked files and blocks are kept. This cannot be undone.",
|
||||||
"changes.amendAndPush": "Amend and Push",
|
"changes.amendAndPush": "Amend and Push",
|
||||||
"changes.pushFailed": "Committed, but the push failed: {{error}}",
|
"changes.pushFailed": "Committed, but the push failed: {{error}}",
|
||||||
"changes.tickFile": "Include {{path}} in the commit",
|
"changes.tickFile": "Include {{path}} in the commit",
|
||||||
|
|||||||
@@ -1396,6 +1396,7 @@
|
|||||||
"noActive": "{{tool}} holds no login on this machine.",
|
"noActive": "{{tool}} holds no login on this machine.",
|
||||||
"noProfiles": "Could not load the profiles.",
|
"noProfiles": "Could not load the profiles.",
|
||||||
"switchSuccess": "Switched {{tool}} to \"{{name}}\". Start a new session to use it.",
|
"switchSuccess": "Switched {{tool}} to \"{{name}}\". Start a new session to use it.",
|
||||||
|
"switchFollowing": "Switched {{tool}} to \"{{name}}\" — running sessions follow it.",
|
||||||
"deleteConfirmBody": "This removes the saved {{tool}} login. To use it again, sign in from a terminal and save it once more.",
|
"deleteConfirmBody": "This removes the saved {{tool}} login. To use it again, sign in from a terminal and save it once more.",
|
||||||
"loginHint": "Add another {{tool}} login by signing in from a terminal first:",
|
"loginHint": "Add another {{tool}} login by signing in from a terminal first:",
|
||||||
"subtitle": {
|
"subtitle": {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
"EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).",
|
"EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).",
|
||||||
"EBADPASSWORD": "Password cannot be empty.",
|
"EBADPASSWORD": "Password cannot be empty.",
|
||||||
"EBADEMAIL": "That email address does not look valid.",
|
"EBADEMAIL": "That email address does not look valid.",
|
||||||
|
"ENAMETAKEN": "Another account already uses that name. Pick one that is yours.",
|
||||||
"EBADFIELD": "One of the values is not valid.",
|
"EBADFIELD": "One of the values is not valid.",
|
||||||
"ENOLANE": "That workspace no longer exists.",
|
"ENOLANE": "That workspace no longer exists.",
|
||||||
"ENOTOWNED": "That workspace is not this user's any more.",
|
"ENOTOWNED": "That workspace is not this user's any more.",
|
||||||
@@ -111,6 +112,8 @@
|
|||||||
"status": "Status",
|
"status": "Status",
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
"statusHint": "A disabled user cannot sign in; their workspaces and history stay.",
|
"statusHint": "A disabled user cannot sign in; their workspaces and history stay.",
|
||||||
|
"hostAccount": "Owns this server's host account",
|
||||||
|
"hostAccountHint": "Sessions run as the server's own Linux account outside every workspace (a script's claude -p, a CLI in a plain terminal) count toward this admin's spend. At most one admin; with none marked, the only admin.",
|
||||||
"selfRole": "You cannot change your own role.",
|
"selfRole": "You cannot change your own role.",
|
||||||
"selfDisable": "You cannot disable yourself.",
|
"selfDisable": "You cannot disable yourself.",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
|
|||||||
@@ -28,6 +28,12 @@
|
|||||||
"unshare": "Bỏ chia sẻ",
|
"unshare": "Bỏ chia sẻ",
|
||||||
"shareDone": "Đã chia sẻ lên Hub",
|
"shareDone": "Đã chia sẻ lên Hub",
|
||||||
"unshareDone": "Đã gỡ khỏi Hub",
|
"unshareDone": "Đã gỡ khỏi Hub",
|
||||||
|
"reshare": "Share lại",
|
||||||
|
"reshareDone": "Đã share bản mới — người đã cài sẽ thấy nút Cập nhật",
|
||||||
|
"reshareSame": "Đã là bản mới nhất — đã share",
|
||||||
|
"reshareAskTitle": "Chọn lại thư mục {{name}}",
|
||||||
|
"reshareAskBody": "Server không có bản của {{name}}. Chọn thư mục của skill (thư mục chứa SKILL.md): bản này sẽ được thay, và người đã cài sẽ thấy nút Cập nhật. Với file .zip, tải lên qua Thêm skill → Tải lên: skill trùng tên sẽ cập nhật mục bạn đã có.",
|
||||||
|
"resharePick": "Chọn thư mục",
|
||||||
"open": "Mở",
|
"open": "Mở",
|
||||||
"copyLink": "Sao chép link",
|
"copyLink": "Sao chép link",
|
||||||
"preview": "Xem",
|
"preview": "Xem",
|
||||||
@@ -137,6 +143,11 @@
|
|||||||
"installsYou": "Bạn",
|
"installsYou": "Bạn",
|
||||||
"installsUnknown": "Người dùng đã xoá",
|
"installsUnknown": "Người dùng đã xoá",
|
||||||
"installsReplaced": "thay bản cũ",
|
"installsReplaced": "thay bản cũ",
|
||||||
|
"versionAt": "Cập nhật {{ago}}",
|
||||||
|
"versionAtFull": "Cập nhật lần cuối {{date}}",
|
||||||
|
"installState.latest": "Bản mới nhất",
|
||||||
|
"installState.outdated": "Bản cũ",
|
||||||
|
"installState.removed": "Đã gỡ",
|
||||||
"installsPluginWhere": "claude plugin install --scope user",
|
"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",
|
"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.",
|
"installsNoneOfYours": "Bạn chưa cài — chỉ hiện các lượt cài của bạn.",
|
||||||
@@ -180,7 +191,7 @@
|
|||||||
"stat.installs": "Lượt cài",
|
"stat.installs": "Lượt cài",
|
||||||
"stat.files": "File",
|
"stat.files": "File",
|
||||||
"stat.size": "Dung lượng",
|
"stat.size": "Dung lượng",
|
||||||
"stat.updated": "Cập nhật",
|
"stat.updated": "Cập nhật lần cuối",
|
||||||
"detail.runAs": "Gọi bằng",
|
"detail.runAs": "Gọi bằng",
|
||||||
"detail.market": "Marketplace",
|
"detail.market": "Marketplace",
|
||||||
"detail.added": "Thêm lúc {{date}}",
|
"detail.added": "Thêm lúc {{date}}",
|
||||||
|
|||||||
@@ -92,6 +92,7 @@
|
|||||||
"changes.expandFile": "Mở {{path}}",
|
"changes.expandFile": "Mở {{path}}",
|
||||||
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
|
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
|
||||||
"changes.fileTruncated": "Diff bị cắt bớt — mở file trong editor để xem phần còn lại.",
|
"changes.fileTruncated": "Diff bị cắt bớt — mở file trong editor để xem phần còn lại.",
|
||||||
|
"changes.lineElided": "… còn {{size}} ký tự trên dòng này",
|
||||||
"changes.commentLine": "Comment dòng {{n}} — dán vào pane của workspace này",
|
"changes.commentLine": "Comment dòng {{n}} — dán vào pane của workspace này",
|
||||||
"changes.commentOn": "Comment {{at}} — dán vào pane bạn dùng gần nhất; chưa gửi gì cho tới khi bạn nhấn Enter ở đó",
|
"changes.commentOn": "Comment {{at}} — dán vào pane bạn dùng gần nhất; chưa gửi gì cho tới khi bạn nhấn Enter ở đó",
|
||||||
"changes.commentSend": "Dán vào pane",
|
"changes.commentSend": "Dán vào pane",
|
||||||
@@ -505,8 +506,6 @@
|
|||||||
"changes.loadMore": "Tải thêm commit",
|
"changes.loadMore": "Tải thêm commit",
|
||||||
"changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi",
|
"changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi",
|
||||||
"changes.splitter": "Đổi độ rộng khung thay đổi",
|
"changes.splitter": "Đổi độ rộng khung thay đổi",
|
||||||
"tree.archiveLiveTitle": "Lưu trữ một session đang chạy?",
|
|
||||||
"tree.archiveLiveBody": "Tab của nó vẫn chạy; lưu trữ chỉ ẩn nó khỏi danh sách này. Bỏ lưu trữ trong mục đã lưu trữ.",
|
|
||||||
"tree.state.awaiting": "đang chờ bạn",
|
"tree.state.awaiting": "đang chờ bạn",
|
||||||
"tree.state.error": "lỗi",
|
"tree.state.error": "lỗi",
|
||||||
"tree.state.live": "đang chạy",
|
"tree.state.live": "đang chạy",
|
||||||
@@ -533,9 +532,9 @@
|
|||||||
"changes.unversionedGroup": "File chưa theo dõi",
|
"changes.unversionedGroup": "File chưa theo dõi",
|
||||||
"changes.commitTicked": "Commit ({{count}})",
|
"changes.commitTicked": "Commit ({{count}})",
|
||||||
"changes.commitAndPush": "Commit và Push",
|
"changes.commitAndPush": "Commit và Push",
|
||||||
"changes.discardAll": "Bỏ tất cả",
|
"changes.discardTicked": "Bỏ ({{count}})",
|
||||||
"changes.discardAllTitle": "Bỏ tất cả thay đổi?",
|
"changes.discardTickedTitle": "Bỏ các thay đổi đã chọn?",
|
||||||
"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.discardTickedConfirm": "Các thay đổi đã chọn trong {{count}} file sẽ trở về commit cuối, và {{untracked}} file mới đã chọn chưa được git theo dõi sẽ bị xoá. File và đoạn không chọn được giữ nguyên. Không thể hoàn tác.",
|
||||||
"changes.amendAndPush": "Amend và Push",
|
"changes.amendAndPush": "Amend và Push",
|
||||||
"changes.pushFailed": "Đã commit, nhưng push thất bại: {{error}}",
|
"changes.pushFailed": "Đã commit, nhưng push thất bại: {{error}}",
|
||||||
"changes.tickFile": "Đưa {{path}} vào commit",
|
"changes.tickFile": "Đưa {{path}} vào commit",
|
||||||
|
|||||||
@@ -1384,6 +1384,7 @@
|
|||||||
"noActive": "{{tool}} chưa có đăng nhập nào trên máy này.",
|
"noActive": "{{tool}} chưa có đăng nhập nào trên máy này.",
|
||||||
"noProfiles": "Không tải được danh sách hồ sơ.",
|
"noProfiles": "Không tải được danh sách hồ sơ.",
|
||||||
"switchSuccess": "Đã chuyển {{tool}} sang \"{{name}}\". Mở phiên mới để dùng.",
|
"switchSuccess": "Đã chuyển {{tool}} sang \"{{name}}\". Mở phiên mới để dùng.",
|
||||||
|
"switchFollowing": "Đã chuyển {{tool}} sang \"{{name}}\" — các phiên đang chạy theo luôn.",
|
||||||
"deleteConfirmBody": "Thao tác này xoá đăng nhập {{tool}} đã lưu. Muốn dùng lại, hãy đăng nhập từ terminal và lưu lại lần nữa.",
|
"deleteConfirmBody": "Thao tác này xoá đăng nhập {{tool}} đã lưu. Muốn dùng lại, hãy đăng nhập từ terminal và lưu lại lần nữa.",
|
||||||
"loginHint": "Thêm đăng nhập {{tool}} khác bằng cách đăng nhập từ terminal trước:",
|
"loginHint": "Thêm đăng nhập {{tool}} khác bằng cách đăng nhập từ terminal trước:",
|
||||||
"subtitle": {
|
"subtitle": {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
"EBADUSER": "Tên đăng nhập: chữ, số, '.', '_' hoặc '-' (tối đa 64 ký tự).",
|
"EBADUSER": "Tên đăng nhập: chữ, số, '.', '_' hoặc '-' (tối đa 64 ký tự).",
|
||||||
"EBADPASSWORD": "Mật khẩu không được để trống.",
|
"EBADPASSWORD": "Mật khẩu không được để trống.",
|
||||||
"EBADEMAIL": "Địa chỉ email không hợp lệ.",
|
"EBADEMAIL": "Địa chỉ email không hợp lệ.",
|
||||||
|
"ENAMETAKEN": "Tên này đã có tài khoản khác dùng. Hãy chọn tên của chính bạn.",
|
||||||
"EBADFIELD": "Có một giá trị không hợp lệ.",
|
"EBADFIELD": "Có một giá trị không hợp lệ.",
|
||||||
"ENOLANE": "Workspace đó không còn tồn tại.",
|
"ENOLANE": "Workspace đó không còn tồn tại.",
|
||||||
"ENOTOWNED": "Workspace đó không còn thuộc người này.",
|
"ENOTOWNED": "Workspace đó không còn thuộc người này.",
|
||||||
@@ -111,6 +112,8 @@
|
|||||||
"status": "Trạng thái",
|
"status": "Trạng thái",
|
||||||
"active": "Đang hoạt động",
|
"active": "Đang hoạt động",
|
||||||
"statusHint": "Người dùng bị vô hiệu hoá không thể đăng nhập; workspace và lịch sử vẫn được giữ.",
|
"statusHint": "Người dùng bị vô hiệu hoá không thể đăng nhập; workspace và lịch sử vẫn được giữ.",
|
||||||
|
"hostAccount": "Sở hữu tài khoản host của máy chủ",
|
||||||
|
"hostAccountHint": "Session chạy bằng tài khoản Linux của máy chủ nhưng ngoài mọi workspace (claude -p của script, CLI trong terminal thường) được tính vào chi phí của admin này. Tối đa một admin; nếu chưa ai được chọn thì là admin duy nhất.",
|
||||||
"selfRole": "Bạn không thể đổi vai trò của chính mình.",
|
"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.",
|
"selfDisable": "Bạn không thể tự vô hiệu hoá mình.",
|
||||||
"password": "Mật khẩu",
|
"password": "Mật khẩu",
|
||||||
|
|||||||
+30
-7
@@ -684,6 +684,8 @@ export interface HostUsageGroup {
|
|||||||
username?: string;
|
username?: string;
|
||||||
/** The user's display name, null when none is set (absent from an older server). */
|
/** The user's display name, null when none is set (absent from an older server). */
|
||||||
displayName?: string | null;
|
displayName?: string | null;
|
||||||
|
/** When the user's avatar was last set (null = none; absent from an older server). */
|
||||||
|
avatarAt?: string | null;
|
||||||
/** % of the whole machine's CPU (100 = every core busy) over the sample. */
|
/** % of the whole machine's CPU (100 = every core busy) over the sample. */
|
||||||
cpu: number;
|
cpu: number;
|
||||||
/** Resident memory, bytes (shared pages count in every process). */
|
/** Resident memory, bytes (shared pages count in every process). */
|
||||||
@@ -798,6 +800,8 @@ export type UserPatch = {
|
|||||||
addWorkspaces?: number[];
|
addWorkspaces?: number[];
|
||||||
removeWorkspaces?: number[];
|
removeWorkspaces?: number[];
|
||||||
signOut?: true;
|
signOut?: true;
|
||||||
|
/** Owns the host's Linux account: its sessions outside every workspace bill to them (admins only). */
|
||||||
|
hostAccount?: boolean;
|
||||||
/** Their own spend quota (`default` = the default applies). */
|
/** Their own spend quota (`default` = the default applies). */
|
||||||
quota?: UserQuotaSetting;
|
quota?: UserQuotaSetting;
|
||||||
};
|
};
|
||||||
@@ -981,6 +985,8 @@ export type AuthUser = {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
/** An admin gave this password: the user must choose their own before anything else. */
|
/** An admin gave this password: the user must choose their own before anything else. */
|
||||||
mustChangePassword?: boolean;
|
mustChangePassword?: boolean;
|
||||||
|
/** Owns the host's Linux account (server/lib/session-account.js) — absent from an older server. */
|
||||||
|
hostAccount?: boolean;
|
||||||
};
|
};
|
||||||
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
|
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
|
||||||
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
|
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
|
||||||
@@ -1399,7 +1405,7 @@ export const api = {
|
|||||||
),
|
),
|
||||||
/** POST /api/tool-profiles/:tool/activate — the outgoing login is saved back first. */
|
/** POST /api/tool-profiles/:tool/activate — the outgoing login is saved back first. */
|
||||||
activate: (tool: string, name: string) =>
|
activate: (tool: string, name: string) =>
|
||||||
request<{ ok: true; name: string; savedOutgoing: string | null }>(
|
request<{ ok: true; name: string; savedOutgoing: string | null; following?: boolean }>(
|
||||||
`/tool-profiles/${encodeURIComponent(tool)}/activate`,
|
`/tool-profiles/${encodeURIComponent(tool)}/activate`,
|
||||||
{ method: "POST", body: JSON.stringify({ name }) }
|
{ method: "POST", body: JSON.stringify({ name }) }
|
||||||
),
|
),
|
||||||
@@ -3318,16 +3324,17 @@ export const api = {
|
|||||||
* several skills at once (one artifact each). `path` keeps a folder's layout. */
|
* several skills at once (one artifact each). `path` keeps a folder's layout. */
|
||||||
uploadSkills: (
|
uploadSkills: (
|
||||||
files: { file: File; path: string }[],
|
files: { file: File; path: string }[],
|
||||||
fields: { name?: string; title?: string; description?: string } = {}
|
/** `id`: Share again — the upload replaces that skill row's version. */
|
||||||
|
fields: { name?: string; title?: string; description?: string; id?: string } = {}
|
||||||
) => {
|
) => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
for (const f of files) form.append("files", f.file, f.file.name);
|
for (const f of files) form.append("files", f.file, f.file.name);
|
||||||
form.append("paths", JSON.stringify(files.map((f) => f.path)));
|
form.append("paths", JSON.stringify(files.map((f) => f.path)));
|
||||||
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
|
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
|
||||||
return request<{ items: Artifact[] }>("/artifacts/skill/upload", {
|
return request<{ items: (Artifact & { replaced?: boolean; changed?: boolean })[] }>(
|
||||||
method: "POST",
|
"/artifacts/skill/upload",
|
||||||
body: form,
|
{ method: "POST", body: form }
|
||||||
});
|
);
|
||||||
},
|
},
|
||||||
/** One file from the user's computer, kept like a pinned one (≤ 25 MB). */
|
/** One file from the user's computer, kept like a pinned one (≤ 25 MB). */
|
||||||
uploadFile: (file: File, fields: { title?: string; description?: string } = {}) => {
|
uploadFile: (file: File, fields: { title?: string; description?: string } = {}) => {
|
||||||
@@ -3366,6 +3373,12 @@ export const api = {
|
|||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
/** POST /api/artifacts/:id/reshare — Share again: the owner's skill folder on
|
||||||
|
* the server becomes the new version (409 ENOSOURCE when it has none). */
|
||||||
|
reshare: (id: number) =>
|
||||||
|
request<{ item: Artifact; changed: boolean }>(`/artifacts/${id}/reshare`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
/** `kept`: a shared one left your library but stays in the Hub. */
|
/** `kept`: a shared one left your library but stays in the Hub. */
|
||||||
remove: (id: number) =>
|
remove: (id: number) =>
|
||||||
request<{ ok: true; kept: boolean }>(`/artifacts/${id}`, { method: "DELETE" }),
|
request<{ ok: true; kept: boolean }>(`/artifacts/${id}`, { method: "DELETE" }),
|
||||||
@@ -4707,11 +4720,21 @@ export interface Artifact {
|
|||||||
suggestedForMe?: boolean;
|
suggestedForMe?: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
/** When its content last changed (a new snapshot); a page or plugin: when it was added. */
|
||||||
|
versionAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/artifacts/:id/installs. `paths` only for your own installs (or an admin). */
|
/** GET /api/artifacts/:id/installs. `paths` only for your own installs (or an admin). */
|
||||||
export interface ArtifactInstalls {
|
export interface ArtifactInstalls {
|
||||||
items: { user: string | null; mine: boolean; at: string; replaced: boolean; paths?: string[] }[];
|
items: {
|
||||||
|
user: string | null;
|
||||||
|
mine: boolean;
|
||||||
|
at: string;
|
||||||
|
replaced: boolean;
|
||||||
|
/** Skills: where that install's copy stands against the newest version now. */
|
||||||
|
state?: "latest" | "outdated" | "removed" | "unknown";
|
||||||
|
paths?: string[];
|
||||||
|
}[];
|
||||||
/** Installs counted before the list was kept. */
|
/** Installs counted before the list was kept. */
|
||||||
unrecorded: number;
|
unrecorded: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
* message. Both ask first when any pane holds an AI agent (it may be
|
* message. Both ask first when any pane holds an AI agent (it may be
|
||||||
* mid-turn); a plain shell holds no session, so it neither asks nor sleeps.
|
* mid-turn); a plain shell holds no session, so it neither asks nor sleeps.
|
||||||
* Used by the tab bar (`ConsoleTabs`), the split panes (`Workspace`) and a
|
* Used by the tab bar (`ConsoleTabs`), the split panes (`Workspace`) and a
|
||||||
* session's right-click menu (`SessionMenu`).
|
* session's right-click menu (`SessionMenu`), and a live session's Archive
|
||||||
|
* (`WorkspaceSessionList`, `WorkspaceHome`) — archiving never leaves a pane running.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -2974,6 +2974,9 @@ export interface LaneDiffLine {
|
|||||||
/** Line number on the right (after) side; null on a removed line. */
|
/** Line number on the right (after) side; null on a removed line. */
|
||||||
newLine: number | null;
|
newLine: number | null;
|
||||||
text: string;
|
text: string;
|
||||||
|
/** Characters cut off the end of a very long row (a base64 blob, a minified
|
||||||
|
* bundle): `text` holds only its start. Absent on a whole row. */
|
||||||
|
elided?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `GET /api/lanes/:id/git/blame` — Annotate: who last changed each line of
|
/** `GET /api/lanes/:id/git/blame` — Annotate: who last changed each line of
|
||||||
|
|||||||
+207
-65
@@ -28,9 +28,14 @@
|
|||||||
* **Installed** instead of the Install button; **Uninstall** (a skill's
|
* **Installed** instead of the Install button; **Uninstall** (a skill's
|
||||||
* copies moved aside, a plugin `claude plugin uninstall`) is offered only in
|
* copies moved aside, a plugin `claude plugin uninstall`) is offered only in
|
||||||
* the card's ⋯ menu. A skill follows its owner's folder (the server takes a
|
* the card's ⋯ menu. A skill follows its owner's folder (the server takes a
|
||||||
* change there as a new snapshot when the Hub is listed); an installed copy
|
* change there as a new snapshot when the Hub is listed; **Share again**
|
||||||
|
* takes its folder on the server — or a plugin's copy — on a click, else
|
||||||
|
* asks for the folder and uploads it onto the same row); an installed copy
|
||||||
* that differs from the newest version shows **Update** (a pack: "Update N"),
|
* that differs from the newest version shows **Update** (a pack: "Update N"),
|
||||||
* which replaces it after a confirmation, the old copy moved aside. Editing a skill
|
* which replaces it after a confirmation, the old copy moved aside. A skill or
|
||||||
|
* file row says when its CONTENT last changed ("Updated 3h ago", `versionAt`),
|
||||||
|
* and the install count's list marks each installer's copy Latest / Old
|
||||||
|
* version / Removed. Editing a skill
|
||||||
* edits its SKILL.md (name, description, instructions — a new snapshot);
|
* edits its SKILL.md (name, description, instructions — a new snapshot);
|
||||||
* its preview opens with what it installs as and when Claude uses it. Deleting a shared
|
* its preview opens with what it installs as and when Claude uses it. Deleting a shared
|
||||||
* row of your own only takes it out of your library — it stays in the Hub
|
* row of your own only takes it out of your library — it stays in the Hub
|
||||||
@@ -48,7 +53,8 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -78,6 +84,7 @@ import { toast } from "../lib/toast";
|
|||||||
import { confirmDialog } from "../lib/confirm";
|
import { confirmDialog } from "../lib/confirm";
|
||||||
import { useDialog } from "../hooks/useDialog";
|
import { useDialog } from "../hooks/useDialog";
|
||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../lib/clipboard";
|
||||||
|
import { timeAgo } from "../lib/format";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
||||||
import {
|
import {
|
||||||
FileUploadPanel,
|
FileUploadPanel,
|
||||||
@@ -276,6 +283,53 @@ export function Artifacts() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Share again: the server's copy of the skill, else the folder picked again
|
||||||
|
// (uploaded onto the same row). Either way installers are offered Update.
|
||||||
|
const reshareInput = useRef<HTMLInputElement>(null);
|
||||||
|
const reshareRow = useRef<Artifact | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
reshareInput.current?.setAttribute("webkitdirectory", "");
|
||||||
|
}, []);
|
||||||
|
const reshared = (changed: boolean) => {
|
||||||
|
toast.success(t(changed ? "reshareDone" : "reshareSame"));
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
const reshare = async (a: Artifact) => {
|
||||||
|
try {
|
||||||
|
const r = await api.artifacts.reshare(a.id);
|
||||||
|
reshared(r.changed);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as { code?: string }).code !== "ENOSOURCE") return toast.error(err);
|
||||||
|
if (
|
||||||
|
!(await confirmDialog({
|
||||||
|
title: t("reshareAskTitle", { name: a.source ?? a.title }),
|
||||||
|
message: t("reshareAskBody", { name: a.source ?? a.title }),
|
||||||
|
confirmLabel: t("resharePick"),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
reshareRow.current = a;
|
||||||
|
reshareInput.current?.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const reshareUpload = async (list: FileList | null) => {
|
||||||
|
const a = reshareRow.current;
|
||||||
|
reshareRow.current = null;
|
||||||
|
if (!a || !list?.length) return;
|
||||||
|
try {
|
||||||
|
const r = await api.artifacts.uploadSkills(
|
||||||
|
Array.from(list).map((file) => ({
|
||||||
|
file,
|
||||||
|
path: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
|
||||||
|
})),
|
||||||
|
{ id: String(a.id) }
|
||||||
|
);
|
||||||
|
reshared(r.items.some((i) => i.changed !== false));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const [renaming, setRenaming] = useState<Artifact | null>(null);
|
const [renaming, setRenaming] = useState<Artifact | null>(null);
|
||||||
|
|
||||||
const remove = async (a: Artifact) => {
|
const remove = async (a: Artifact) => {
|
||||||
@@ -441,6 +495,7 @@ export function Artifacts() {
|
|||||||
const actionsFor = (a: Artifact): CardActions => ({
|
const actionsFor = (a: Artifact): CardActions => ({
|
||||||
onOpen: () => setDetailId(a.id),
|
onOpen: () => setDetailId(a.id),
|
||||||
onShare: () => void share(a),
|
onShare: () => void share(a),
|
||||||
|
onReshare: () => void reshare(a),
|
||||||
onRename: () => {
|
onRename: () => {
|
||||||
setDetailId(null);
|
setDetailId(null);
|
||||||
setRenaming(a);
|
setRenaming(a);
|
||||||
@@ -571,6 +626,17 @@ export function Artifacts() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in space-y-5" data-testid="artifacts-page">
|
<div className="animate-fade-in space-y-5" data-testid="artifacts-page">
|
||||||
|
<input
|
||||||
|
ref={reshareInput}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
data-testid="artifact-reshare-folder"
|
||||||
|
onChange={(e) => {
|
||||||
|
void reshareUpload(e.target.files);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||||
@@ -1065,6 +1131,8 @@ function EmptyState({
|
|||||||
type CardActions = {
|
type CardActions = {
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
onShare: () => void;
|
onShare: () => void;
|
||||||
|
/** A skill of your own: its newest version, shared (installers get Update). */
|
||||||
|
onReshare: () => void;
|
||||||
onRename: () => void;
|
onRename: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onInstall: () => void;
|
onInstall: () => void;
|
||||||
@@ -1207,6 +1275,7 @@ function Meta({
|
|||||||
server: boolean;
|
server: boolean;
|
||||||
date?: boolean;
|
date?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation("artifacts");
|
||||||
const parts: React.ReactNode[] = [];
|
const parts: React.ReactNode[] = [];
|
||||||
if (server && a.owner && !a.mine)
|
if (server && a.owner && !a.mine)
|
||||||
parts.push(
|
parts.push(
|
||||||
@@ -1214,7 +1283,18 @@ function Meta({
|
|||||||
{a.owner}
|
{a.owner}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
if (date)
|
// A skill or file is a snapshot that changes: when it last did. Anything else: when added.
|
||||||
|
if (a.kind === "skill" || a.kind === "file")
|
||||||
|
parts.push(
|
||||||
|
<span
|
||||||
|
key="at"
|
||||||
|
className="shrink-0"
|
||||||
|
title={t("versionAtFull", { date: new Date(a.versionAt).toLocaleString() })}
|
||||||
|
>
|
||||||
|
{t("versionAt", { ago: timeAgo(a.versionAt) })}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
else if (date)
|
||||||
parts.push(
|
parts.push(
|
||||||
<span key="at" className="shrink-0" title={new Date(a.createdAt).toLocaleString()}>
|
<span key="at" className="shrink-0" title={new Date(a.createdAt).toLocaleString()}>
|
||||||
{fmtDate(a.createdAt)}
|
{fmtDate(a.createdAt)}
|
||||||
@@ -1236,6 +1316,7 @@ function ArtifactMenu({
|
|||||||
menu,
|
menu,
|
||||||
onOpen,
|
onOpen,
|
||||||
onShare,
|
onShare,
|
||||||
|
onReshare,
|
||||||
onRename,
|
onRename,
|
||||||
onDelete,
|
onDelete,
|
||||||
onInstall,
|
onInstall,
|
||||||
@@ -1296,6 +1377,9 @@ function ArtifactMenu({
|
|||||||
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
|
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
|
{server && a.kind === "skill" && (
|
||||||
|
<MenuItem onSelect={pick(onReshare)}>{t("reshare")}</MenuItem>
|
||||||
|
)}
|
||||||
<MenuItem onSelect={pick(onRename)}>{t("rename")}</MenuItem>
|
<MenuItem onSelect={pick(onRename)}>{t("rename")}</MenuItem>
|
||||||
{!hubOnly && (
|
{!hubOnly && (
|
||||||
<MenuItem danger onSelect={pick(onDelete)}>
|
<MenuItem danger onSelect={pick(onDelete)}>
|
||||||
@@ -1446,6 +1530,7 @@ function DetailDialog({
|
|||||||
canInstall,
|
canInstall,
|
||||||
onClose,
|
onClose,
|
||||||
onShare,
|
onShare,
|
||||||
|
onReshare,
|
||||||
onRename,
|
onRename,
|
||||||
onDelete,
|
onDelete,
|
||||||
onInstall,
|
onInstall,
|
||||||
@@ -1467,7 +1552,10 @@ function DetailDialog({
|
|||||||
: []),
|
: []),
|
||||||
...(a.files.length ? [{ label: t("stat.files"), value: String(a.files.length) }] : []),
|
...(a.files.length ? [{ label: t("stat.files"), value: String(a.files.length) }] : []),
|
||||||
...(a.size ? [{ label: t("stat.size"), value: fmtBytes(a.size) }] : []),
|
...(a.size ? [{ label: t("stat.size"), value: fmtBytes(a.size) }] : []),
|
||||||
{ label: t("stat.updated"), value: fmtDate(a.updatedAt) },
|
{
|
||||||
|
label: t("stat.updated"),
|
||||||
|
value: new Date(a.versionAt).toLocaleString(),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<Modal title={a.title} onClose={onClose} size="md">
|
<Modal title={a.title} onClose={onClose} size="md">
|
||||||
@@ -1610,6 +1698,11 @@ function DetailDialog({
|
|||||||
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
|
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{server && a.kind === "skill" && (
|
||||||
|
<button type="button" className={secondary} onClick={onReshare}>
|
||||||
|
{t("reshare")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button type="button" className={secondary} onClick={onRename}>
|
<button type="button" className={secondary} onClick={onRename}>
|
||||||
{t("rename")}
|
{t("rename")}
|
||||||
</button>
|
</button>
|
||||||
@@ -1629,12 +1722,48 @@ function DetailDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "N installs" on a card; a click lists who installed it, when, and where. */
|
/** "N installs" on a card; a click lists who installed it, when, and where.
|
||||||
|
* The list floats over the page (a portal at the button, below it or above
|
||||||
|
* when there is no room, kept inside the window): inside the card list's
|
||||||
|
* `overflow-hidden` it was cut off at the last card. */
|
||||||
|
/** An installer's copy against the newest version: green, amber, grey. */
|
||||||
|
const STATE_CLASS = {
|
||||||
|
latest: "bg-status-success/15 text-status-success",
|
||||||
|
outdated: "bg-status-warning/15 text-status-warning",
|
||||||
|
removed: "bg-surface-2 text-fg-muted",
|
||||||
|
} as const;
|
||||||
|
|
||||||
function InstallsButton({ item }: { item: Artifact }) {
|
function InstallsButton({ item }: { item: Artifact }) {
|
||||||
const { t } = useTranslation("artifacts");
|
const { t } = useTranslation("artifacts");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [data, setData] = useState<ArtifactInstalls | null>(null);
|
const [data, setData] = useState<ArtifactInstalls | null>(null);
|
||||||
const box = useRef<HTMLSpanElement>(null);
|
const box = useRef<HTMLSpanElement>(null);
|
||||||
|
const pop = useRef<HTMLDivElement>(null);
|
||||||
|
const [at, setAt] = useState<{ left: number; top: number } | null>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) return setAt(null);
|
||||||
|
const place = () => {
|
||||||
|
const b = box.current?.getBoundingClientRect();
|
||||||
|
const p = pop.current;
|
||||||
|
if (!b || !p) return;
|
||||||
|
const w = p.offsetWidth;
|
||||||
|
const h = p.offsetHeight;
|
||||||
|
const gap = 4;
|
||||||
|
const below = b.bottom + gap + h <= window.innerHeight - 8 || b.top - gap - h < 8;
|
||||||
|
setAt({
|
||||||
|
left: Math.max(8, Math.min(b.left, window.innerWidth - w - 8)),
|
||||||
|
top: below ? b.bottom + gap : b.top - gap - h,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
place();
|
||||||
|
window.addEventListener("resize", place);
|
||||||
|
window.addEventListener("scroll", place, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", place);
|
||||||
|
window.removeEventListener("scroll", place, true);
|
||||||
|
};
|
||||||
|
}, [open, data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -1647,7 +1776,8 @@ function InstallsButton({ item }: { item: Artifact }) {
|
|||||||
toast.error(err);
|
toast.error(err);
|
||||||
});
|
});
|
||||||
const onDown = (e: MouseEvent) => {
|
const onDown = (e: MouseEvent) => {
|
||||||
if (!box.current?.contains(e.target as Node)) setOpen(false);
|
const n = e.target as Node;
|
||||||
|
if (!box.current?.contains(n) && !pop.current?.contains(n)) setOpen(false);
|
||||||
};
|
};
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") setOpen(false);
|
if (e.key === "Escape") setOpen(false);
|
||||||
@@ -1670,66 +1800,78 @@ function InstallsButton({ item }: { item: Artifact }) {
|
|||||||
>
|
>
|
||||||
{t("installs", { count: item.installs })}
|
{t("installs", { count: item.installs })}
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open &&
|
||||||
<div
|
createPortal(
|
||||||
role="dialog"
|
<div
|
||||||
aria-label={t("installsTitle")}
|
ref={pop}
|
||||||
data-testid="artifact-installs"
|
role="dialog"
|
||||||
className="absolute left-0 z-30 mt-1 w-80 max-w-[80vw] rounded-lg border border-border bg-surface-1 p-2 shadow-xl"
|
aria-label={t("installsTitle")}
|
||||||
>
|
data-testid="artifact-installs"
|
||||||
<p className="mb-1.5 px-1 text-xs font-medium text-fg-primary">{t("installsTitle")}</p>
|
style={at ? { left: at.left, top: at.top } : { left: 0, top: 0, visibility: "hidden" }}
|
||||||
{data === null ? (
|
className="fixed z-[60] w-80 max-w-[80vw] rounded-lg border border-border bg-surface-1 p-2 shadow-xl"
|
||||||
<p className="px-1 text-xs text-fg-muted">{t("loading")}</p>
|
>
|
||||||
) : (
|
<p className="mb-1.5 px-1 text-xs font-medium text-fg-primary">{t("installsTitle")}</p>
|
||||||
<ul className="max-h-64 space-y-1 overflow-auto">
|
{data === null ? (
|
||||||
{data.items.map((i, n) => (
|
<p className="px-1 text-xs text-fg-muted">{t("loading")}</p>
|
||||||
<li key={n} className="rounded px-1.5 py-1 text-xs hover:bg-surface-2">
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<ul className="max-h-64 space-y-1 overflow-auto">
|
||||||
<span className="min-w-0 flex-1 truncate text-fg-primary">
|
{data.items.map((i, n) => (
|
||||||
{i.mine ? t("installsYou") : (i.user ?? t("installsUnknown"))}
|
<li key={n} className="rounded px-1.5 py-1 text-xs hover:bg-surface-2">
|
||||||
{i.replaced && (
|
<div className="flex items-center gap-2">
|
||||||
<span className="ml-1 text-fg-muted">· {t("installsReplaced")}</span>
|
<span className="min-w-0 flex-1 truncate text-fg-primary">
|
||||||
)}
|
{i.mine ? t("installsYou") : (i.user ?? t("installsUnknown"))}
|
||||||
</span>
|
{i.replaced && (
|
||||||
<span className="shrink-0 text-[11px] text-fg-muted">
|
<span className="ml-1 text-fg-muted">· {t("installsReplaced")}</span>
|
||||||
{new Date(i.at).toLocaleString()}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
{i.state && i.state !== "unknown" && (
|
||||||
{i.paths && (
|
<span
|
||||||
<div className="mt-0.5 space-y-0.5">
|
data-testid="install-state"
|
||||||
{i.paths.length ? (
|
className={`shrink-0 rounded px-1 text-[10px] ${STATE_CLASS[i.state]}`}
|
||||||
i.paths.map((p) => (
|
>
|
||||||
<p
|
{t(`installState.${i.state}`)}
|
||||||
key={p}
|
</span>
|
||||||
className="truncate font-mono text-[10px] text-fg-muted"
|
|
||||||
title={p}
|
|
||||||
>
|
|
||||||
{p}
|
|
||||||
</p>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="font-mono text-[10px] text-fg-muted">
|
|
||||||
{t("installsPluginWhere")}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
<span className="shrink-0 text-[11px] text-fg-muted">
|
||||||
|
{new Date(i.at).toLocaleString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{i.paths && (
|
||||||
</li>
|
<div className="mt-0.5 space-y-0.5">
|
||||||
))}
|
{i.paths.length ? (
|
||||||
{data.unrecorded > 0 && (
|
i.paths.map((p) => (
|
||||||
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
|
<p
|
||||||
{t("installsUnrecorded", { count: data.unrecorded })}
|
key={p}
|
||||||
</li>
|
className="truncate font-mono text-[10px] text-fg-muted"
|
||||||
)}
|
title={p}
|
||||||
{data.items.length === 0 && data.unrecorded === 0 && (
|
>
|
||||||
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
|
{p}
|
||||||
{t("installsNoneOfYours")}
|
</p>
|
||||||
</li>
|
))
|
||||||
)}
|
) : (
|
||||||
</ul>
|
<p className="font-mono text-[10px] text-fg-muted">
|
||||||
)}
|
{t("installsPluginWhere")}
|
||||||
</div>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{data.unrecorded > 0 && (
|
||||||
|
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
|
||||||
|
{t("installsUnrecorded", { count: data.unrecorded })}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{data.items.length === 0 && data.unrecorded === 0 && (
|
||||||
|
<li className="px-1.5 py-1 text-[11px] text-fg-muted">
|
||||||
|
{t("installsNoneOfYours")}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ vi.mock("../../lib/api", () => ({
|
|||||||
addPlugin: vi.fn(),
|
addPlugin: vi.fn(),
|
||||||
inspectMarket: vi.fn(),
|
inspectMarket: vi.fn(),
|
||||||
uploadSkills: vi.fn(),
|
uploadSkills: vi.fn(),
|
||||||
|
reshare: vi.fn(),
|
||||||
writeSkill: vi.fn(),
|
writeSkill: vi.fn(),
|
||||||
uploadFile: vi.fn(),
|
uploadFile: vi.fn(),
|
||||||
candidates: vi.fn(),
|
candidates: vi.fn(),
|
||||||
@@ -76,6 +77,7 @@ const item = (over: Partial<Artifact>): Artifact => ({
|
|||||||
installs: 0,
|
installs: 0,
|
||||||
createdAt: "2026-10-14T00:00:00Z",
|
createdAt: "2026-10-14T00:00:00Z",
|
||||||
updatedAt: "2026-10-14T00:00:00Z",
|
updatedAt: "2026-10-14T00:00:00Z",
|
||||||
|
versionAt: "2026-10-14T00:00:00Z",
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -348,9 +350,16 @@ describe("Artifacts", () => {
|
|||||||
mine: true,
|
mine: true,
|
||||||
at: "2026-10-19T10:00:00Z",
|
at: "2026-10-19T10:00:00Z",
|
||||||
replaced: false,
|
replaced: false,
|
||||||
|
state: "latest",
|
||||||
paths: ["/h/.claude/skills/deploy"],
|
paths: ["/h/.claude/skills/deploy"],
|
||||||
},
|
},
|
||||||
{ user: "khoa", mine: false, at: "2026-10-18T10:00:00Z", replaced: true },
|
{
|
||||||
|
user: "khoa",
|
||||||
|
mine: false,
|
||||||
|
at: "2026-10-18T10:00:00Z",
|
||||||
|
replaced: true,
|
||||||
|
state: "outdated",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
unrecorded: 1,
|
unrecorded: 1,
|
||||||
});
|
});
|
||||||
@@ -362,6 +371,17 @@ describe("Artifacts", () => {
|
|||||||
expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument();
|
expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument();
|
||||||
expect(within(box).getByText(/khoa/)).toBeInTheDocument();
|
expect(within(box).getByText(/khoa/)).toBeInTheDocument();
|
||||||
expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument();
|
expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument();
|
||||||
|
// Each install says whether its copy is the newest version.
|
||||||
|
expect(
|
||||||
|
within(box)
|
||||||
|
.getAllByTestId("install-state")
|
||||||
|
.map((b) => b.textContent)
|
||||||
|
).toEqual(["Latest", "Old version"]);
|
||||||
|
// Over the page, outside the card list that clips its overflow — and a
|
||||||
|
// click inside it keeps it open.
|
||||||
|
expect(box.parentElement).toBe(document.body);
|
||||||
|
await user.click(within(box).getByText(/khoa/));
|
||||||
|
expect(screen.getByTestId("artifact-installs")).toBeInTheDocument();
|
||||||
await user.keyboard("{Escape}");
|
await user.keyboard("{Escape}");
|
||||||
expect(screen.queryByTestId("artifact-installs")).toBeNull();
|
expect(screen.queryByTestId("artifact-installs")).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -389,6 +409,65 @@ describe("Artifacts", () => {
|
|||||||
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
|
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Share again takes the server's copy, else uploads the picked folder onto that skill", async () => {
|
||||||
|
vi.mocked(api.artifacts.uploadSkills).mockClear();
|
||||||
|
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
||||||
|
serverMode: true,
|
||||||
|
canShare: true,
|
||||||
|
installsEnabled: true,
|
||||||
|
canManage: true,
|
||||||
|
});
|
||||||
|
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||||
|
items: [item({ id: 2, kind: "skill", title: "sg-flow", source: "sg-flow", shared: true })],
|
||||||
|
});
|
||||||
|
vi.mocked(api.artifacts.reshare).mockResolvedValueOnce({
|
||||||
|
item: item({ id: 2 }),
|
||||||
|
changed: true,
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
const card = await screen.findByTestId("artifact-2");
|
||||||
|
await menuAction(user, "Share again", card);
|
||||||
|
await waitFor(() => expect(api.artifacts.reshare).toHaveBeenCalledWith(2));
|
||||||
|
expect(api.artifacts.uploadSkills).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// No copy on the server: the folder is asked for and lands on THIS row.
|
||||||
|
vi.mocked(api.artifacts.reshare).mockRejectedValueOnce(
|
||||||
|
Object.assign(new Error("no copy"), { code: "ENOSOURCE" })
|
||||||
|
);
|
||||||
|
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||||
|
vi.mocked(api.artifacts.uploadSkills).mockResolvedValueOnce({
|
||||||
|
items: [{ ...item({ id: 2 }), changed: true }],
|
||||||
|
});
|
||||||
|
await menuAction(user, "Share again", card);
|
||||||
|
await waitFor(() => expect(confirmDialog).toHaveBeenCalled());
|
||||||
|
const md = new File(["---\nname: sg-flow\n---\n"], "SKILL.md");
|
||||||
|
await user.upload(screen.getByTestId("artifact-reshare-folder"), md);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.artifacts.uploadSkills).toHaveBeenCalledWith([{ file: md, path: "SKILL.md" }], {
|
||||||
|
id: "2",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says when a skill's content last changed, not when it was renamed", async () => {
|
||||||
|
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
item({
|
||||||
|
id: 2,
|
||||||
|
kind: "skill",
|
||||||
|
title: "deploy",
|
||||||
|
source: "deploy",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
versionAt: new Date(Date.now() - 3 * 3600_000).toISOString(),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
open();
|
||||||
|
const card = await screen.findByTestId("artifact-2");
|
||||||
|
expect(within(card).getByText("Updated 3h ago")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("offers Update for an installed skill its owner changed, after a confirmation", async () => {
|
it("offers Update for an installed skill its owner changed, after a confirmation", async () => {
|
||||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.8.36 (rebuild client to match)
|
UI build v2.8.47 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+16
-9
@@ -89,9 +89,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/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
| `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` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 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, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), 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). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. 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 `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 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?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), 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). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||||
|
|
||||||
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.
|
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, `409 ENAMETAKEN` for a `displayName` that reads as another account's (the same rule as `PATCH /api/me/account`), `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, keyed by tool: `claude`, `codex`, `cursor`); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool); `cursor: [{name, kind: "apikey", addedAt, livePanes, …}]` is the [Cursor account pool](SERVER-MODE.md#cursor-account-pool) (API keys; `quota` null, spend 0); `groups: [{id, name, createdAt, users: [{userId, username}], accounts: {claude: [names], codex: […], cursor: […]}}]` are the [pool groups](SERVER-MODE.md#account-pool-groups) (empty in local mode). `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).
|
`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, keyed by tool: `claude`, `codex`, `cursor`); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool); `cursor: [{name, kind: "apikey", addedAt, livePanes, …}]` is the [Cursor account pool](SERVER-MODE.md#cursor-account-pool) (API keys; `quota` null, spend 0); `groups: [{id, name, createdAt, users: [{userId, username}], accounts: {claude: [names], codex: […], cursor: […]}}]` are the [pool groups](SERVER-MODE.md#account-pool-groups) (empty in local mode). `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).
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
|
|||||||
| `PUT /api/me/pool-accounts` `{tool, account: "<name>" \| null}` | the account's new sessions of `tool` run on `account` (`null` = automatic): `{tool, choice, switched}` — `switched` as for an admin's pin (a Claude login goes into the home at once). Below an admin's pin (`409 ELOCKED`), only an account the group allows (`404 ENOACCOUNT`), `400 EBADTOOL`; login cookie only, audited |
|
| `PUT /api/me/pool-accounts` `{tool, account: "<name>" \| null}` | the account's new sessions of `tool` run on `account` (`null` = automatic): `{tool, choice, switched}` — `switched` as for an admin's pin (a Claude login goes into the home at once). Below an admin's pin (`409 ELOCKED`), only an account the group allows (`404 ENOACCOUNT`), `400 EBADTOOL`; login cookie only, audited |
|
||||||
| `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) |
|
| `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"}` |
|
| `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` |
|
| `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`; `409 ENAMETAKEN` for a display name that reads as another account's display name or username (marks, case, punctuation, bracketed parts like "(you)" and look-alike letters ignored; keeping the name you have is always allowed) |
|
||||||
| `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/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 |
|
| `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 |
|
| `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 |
|
||||||
@@ -817,6 +817,11 @@ that file.
|
|||||||
- Three caps guard the response: **300** files listed, **200 KB** of patch per
|
- Three caps guard the response: **300** files listed, **200 KB** of patch per
|
||||||
file, **1 MB** of patch in a `full` read. A cap that bit sets `truncated` on
|
file, **1 MB** of patch in a `full` read. A cap that bit sets `truncated` on
|
||||||
the response and on the file it cut.
|
the response and on the file it cut.
|
||||||
|
- A row longer than **4,000** characters (an inlined base64 image or video, a
|
||||||
|
minified bundle) comes back cut to its first 1,000 with `elided: <characters
|
||||||
|
hidden>`, BEFORE the per-file cap is applied, so one huge line no longer eats
|
||||||
|
the hunks after it. Each file's raw patch is read up to 16 M characters; past
|
||||||
|
that it is still `truncated`.
|
||||||
- Read-only, no same-origin guard. `available: false` (HTTP **200**) for a cwd
|
- Read-only, no same-origin guard. `available: false` (HTTP **200**) for a cwd
|
||||||
git cannot read; `range=branch` compares against the workspace's base branch,
|
git cannot read; `range=branch` compares against the workspace's base branch,
|
||||||
else the repo's default branch (what `origin/HEAD` names, else `main`/`master`)
|
else the repo's default branch (what `origin/HEAD` names, else `main`/`master`)
|
||||||
@@ -1058,7 +1063,7 @@ cannot collide on `index.lock`.
|
|||||||
| `push` | `git push -u <remote> HEAD` — the branch's upstream remote, else the only remote, else `origin`, else `400 ENOREMOTE` | — |
|
| `push` | `git push -u <remote> HEAD` — the branch's upstream remote, else the only remote, else `origin`, else `400 ENOREMOTE` | — |
|
||||||
| `stage` | `git add -- <paths>` | `{"paths": ["..."]}` |
|
| `stage` | `git add -- <paths>` | `{"paths": ["..."]}` |
|
||||||
| `unstage` | `git restore --staged -- <paths>` (a staged rename also passes its original path, so both halves unstage) | `{"paths": ["..."]}` |
|
| `unstage` | `git restore --staged -- <paths>` (a staged rename also passes its original path, so both halves unstage) | `{"paths": ["..."]}` |
|
||||||
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked) | `{"paths": ["..."], "confirm": true}` |
|
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked); with `hunks` (a partly ticked file's ticked change blocks, named as in `commit`), `git apply -R` of only those blocks off the working tree first — every patch built before any is applied, so a stale one is `409 EHUNKGONE` with nothing undone | `{"paths": ["..."], "confirm": true}` / `{"paths": [], "hunks": {"<path>": [{"hunk": "@@ … @@", "blocks": [0]}]}, "context": 3, "confirm": true}` |
|
||||||
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` — or, with `block`, of only that hunk's `block`-th run of +/- lines (the other runs folded into context, as an edited `git add -p` hunk) | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "block": 0, "context": 3}` |
|
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` — or, with `block`, of only that hunk's `block`-th run of +/- lines (the other runs folded into context, as an edited `git add -p` hunk) | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "block": 0, "context": 3}` |
|
||||||
| `discard-hunk` | `git apply -R` of that one hunk (or that one `block` of it), off the working tree | `{"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}` |
|
| `discard-hunk` | `git apply -R` of that one hunk (or that one `block` of it), off the working tree | `{"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}` |
|
||||||
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"`, or — with `paths` — `git commit --only -- <paths>`: exactly those files' working-tree content (an untracked one is added first, and taken back out if the commit fails), every other change left as it was | `{"message": "...", "only": "staged"}` / `{"message": "...", "paths": ["..."]}` |
|
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"`, or — with `paths` — `git commit --only -- <paths>`: exactly those files' working-tree content (an untracked one is added first, and taken back out if the commit fails), every other change left as it was | `{"message": "...", "only": "staged"}` / `{"message": "...", "paths": ["..."]}` |
|
||||||
@@ -3036,7 +3041,7 @@ POST /api/tool-profiles/:tool/activate Body: { name }
|
|||||||
DELETE /api/tool-profiles/:tool Body: { name }
|
DELETE /api/tool-profiles/:tool Body: { name }
|
||||||
```
|
```
|
||||||
|
|
||||||
The login profiles of the AI tools other than Claude Code (`server/lib/tool-profiles.js`; Claude's are `/api/cc-config/profiles` above). Settings → AI tools → **Profiles** shows both, one tab per tool. `:tool` is `codex` (the whole `$CODEX_HOME/auth.json`: a ChatGPT login or an API key) or `omp` (every credential Oh My Pi stores, read with `omp auth export` and written back with `omp auth import -` — secrets on stdin, never argv; import merges, so a switch first empties omp's credential tables in `~/.omp/agent/agent.db`, and a failed import puts the outgoing set back). `antigravity` is listed with `supported: false` and a `reason`: agy keeps its login in the OS keyring, which AgentHub does not touch (`409 EUNSUPPORTED`). `GET /` answers `{tools: [{tool, supported, reason?, items, active, error?}]}`, each item `{name, label, savedAt, active, kind, email, detail}` — never a token; one tool's failure (omp not installed) is that tool's `error`, not the request's. Profiles are stored one file each under `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (0700/0600) with cc-profiles' rules: one login is one profile (`409 EDUPLICATE`), a switch re-saves the outgoing login into its own profile first and refuses one never saved (`409 EUNSAVED`), the live login's profile is not deletable (`409 EACTIVE`), `409 ENOAUTH` when the tool holds no login, `409 ENOTINSTALLED`, `502 EOMP` when omp's own command fails. Writes are same-origin only; in server mode `/tool-profiles` is in `ADMIN_WRITE_PREFIXES` and `STAFF_ONLY_PREFIXES` — it changes the HOST's own logins.
|
The login profiles of the AI tools other than Claude Code (`server/lib/tool-profiles.js`; Claude's are `/api/cc-config/profiles` above). Settings → AI tools → **Profiles** shows both, one tab per tool. `:tool` is `codex` (the whole `$CODEX_HOME/auth.json`: a ChatGPT login or an API key) or `omp` (every credential Oh My Pi stores, read with `omp auth export` and written back with `omp auth import -` — secrets on stdin, never argv; import merges, so a switch first empties omp's credential tables in `~/.omp/agent/agent.db`, and a failed import puts the outgoing set back). `antigravity` is listed with `supported: false` and a `reason`: agy keeps its login in the OS keyring, which AgentHub does not touch (`409 EUNSUPPORTED`). `GET /` answers `{tools: [{tool, supported, reason?, items, active, error?}]}`, each item `{name, label, savedAt, active, kind, email, detail}` — never a token; one tool's failure (omp not installed) is that tool's `error`, not the request's. Profiles are stored one file each under `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (0700/0600) with cc-profiles' rules: one login is one profile (`409 EDUPLICATE`), a switch re-saves the outgoing login into its own profile first and refuses one never saved (`409 EUNSAVED`), and for Codex then runs `codex app-server daemon restart` when that CODEX_HOME's shared daemon is running (its `app-server-control.sock` resolves) — the daemon holds the login it started with, so without it neither an open nor a NEW Codex session saw the switch; open TUIs reconnect on their own — answering `following: true` (the activate response's extra field; `false` when no daemon ran), the live login's profile is not deletable (`409 EACTIVE`), `409 ENOAUTH` when the tool holds no login, `409 ENOTINSTALLED`, `502 EOMP` when omp's own command fails. Writes are same-origin only; in server mode `/tool-profiles` is in `ADMIN_WRITE_PREFIXES` and `STAFF_ONLY_PREFIXES` — it changes the HOST's own logins.
|
||||||
|
|
||||||
### AI Proxy profiles
|
### AI Proxy profiles
|
||||||
|
|
||||||
@@ -3142,7 +3147,7 @@ A snapshot freezes a session as it is at that moment into one immutable `session
|
|||||||
### Artifacts hub
|
### Artifacts hub
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed` and `updateAvailable` (default scope `all`)
|
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed`, `updateAvailable` and `versionAt` (when its content last changed — a new snapshot; a rename or share toggle moves only `updatedAt`) (default scope `all`)
|
||||||
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
|
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
|
||||||
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub
|
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub
|
||||||
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
|
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
|
||||||
@@ -3151,7 +3156,7 @@ POST /api/artifacts/page {url, title?, description?}
|
|||||||
POST /api/artifacts/file {laneId, path, title?, sessionId?}
|
POST /api/artifacts/file {laneId, path, title?, sessionId?}
|
||||||
POST /api/artifacts/file/upload multipart: files (one), title?, description? → {item}
|
POST /api/artifacts/file/upload multipart: files (one), title?, description? → {item}
|
||||||
POST /api/artifacts/skill {name, laneId?, title?, description?}
|
POST /api/artifacts/skill {name, laneId?, title?, description?}
|
||||||
POST /api/artifacts/skill/upload multipart: files, paths (JSON, each file's relative path), name?, title?, description? → {items}
|
POST /api/artifacts/skill/upload multipart: files, paths (JSON, each file's relative path), name?, title?, description?, id? → {items} — a skill named like one the caller already has becomes its new version (`replaced`, `changed`); `id` = Share again onto that row
|
||||||
POST /api/artifacts/skill/new {name, description, content, title?} → {item, items}
|
POST /api/artifacts/skill/new {name, description, content, title?} → {item, items}
|
||||||
POST /api/artifacts/plugin/inspect {market} → {market, name, description, plugins: [{name, key, description, version, category}]}
|
POST /api/artifacts/plugin/inspect {market} → {market, name, description, plugins: [{name, key, description, version, category}]}
|
||||||
POST /api/artifacts/plugin {key: "name@marketplace", market: "owner/repo" | git URL | marketplace.json URL, title?, description?}
|
POST /api/artifacts/plugin {key: "name@marketplace", market: "owner/repo" | git URL | marketplace.json URL, title?, description?}
|
||||||
@@ -3161,7 +3166,8 @@ DELETE /api/artifacts/:id {ok, kept} — kept: a shared row
|
|||||||
GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff)
|
GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff)
|
||||||
POST /api/artifacts/:id/install {replace?, update?} → {installed, replaced?}
|
POST /api/artifacts/:id/install {replace?, update?} → {installed, replaced?}
|
||||||
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
|
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
|
||||||
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded}
|
POST /api/artifacts/:id/reshare → {item, changed} — Share again (skill, owner): the owner's folder on this server becomes the new version and the row is shared; 409 ENOSOURCE when the server has no copy
|
||||||
|
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, state?, paths?}], unrecorded} — state (skills): latest | outdated | removed | unknown, that install's Claude skill copies against the newest version now
|
||||||
POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin
|
POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin
|
||||||
POST /api/artifacts/:id/decline → {id, declined} — the suggested user
|
POST /api/artifacts/:id/decline → {id, declined} — the suggested user
|
||||||
POST /api/artifacts/:id/assign {userId} → {item} — admin
|
POST /api/artifacts/:id/assign {userId} → {item} — admin
|
||||||
@@ -3170,8 +3176,9 @@ POST /api/artifacts/:id/assign {userId} → {item} — admin
|
|||||||
What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`):
|
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 — or a Claude Docs `batch` that creates a doc (`container.create`; its title is the doc's name, later batches are skipped) — is captured from its PostToolUse hook (a doc's own `frame.artifactUrl`; for a publish 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, with `suggested_owner_id` set to the owner of the workspace whose folder holds the session's `cwd` (else, under S3, the member whose home it is).
|
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish — or a Claude Docs `batch` that creates a doc (`container.create`; its title is the doc's name, later batches are skipped) — is captured from its PostToolUse hook (a doc's own `frame.artifactUrl`; for a publish 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, with `suggested_owner_id` set to the owner of the workspace whose folder holds the session's `cwd` (else, under S3, the member whose home it is).
|
||||||
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
|
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked and not a web app's own page: never an `index.html`, one under `www`/`public`/`dist`/`build`/`src`/`static`/`templates`/`assets`/`site`/`_site`, or one beside `.js`/`.css`-type files in a folder other than the workspace root — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. 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`, `id_rsa`, `.netrc`…, or a data file named `*token*`/`*secret*`/`*password*`/`*credential*` — the word alone does not count in Markdown, source code or images, e.g. `design-tokens.md`) `403 ESECRET`.
|
- **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`, `id_rsa`, `.netrc`…, or a data file named `*token*`/`*secret*`/`*password*`/`*credential*` — the word alone does not count in Markdown, source code or images, e.g. `design-tokens.md`) `403 ESECRET`.
|
||||||
|
- **Share again** (a skill's ⋯ menu and detail sheet, its owner, server mode) — `reshare` takes the owner's skill folder on THIS server as the new snapshot: the row's source folder, else the same-named `skills/<name>` of a plugin the owner installed at user scope (`installed_plugins.json`, the most recently updated one, never outside their `~/.claude/plugins/`), and shares the row; installers are then offered **Update**. With no copy on the server (a skill uploaded from a browser) it answers `409 ENOSOURCE` and the page asks for the skill's folder, uploaded with `id` onto that row. An upload whose skill is named like one the uploader already has (not a workspace's) also replaces that row's version rather than adding a twin.
|
||||||
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+22
-2
@@ -372,7 +372,13 @@ Wherever the dashboard names a person — the sidebar's sign-out tooltip, the
|
|||||||
workspace list's owner groups, the Users page and its audit log, the Edit user
|
workspace list's owner groups, the Users page and its audit log, the Edit user
|
||||||
form — it shows their display name when one is set, else the username (the
|
form — it shows their display name when one is set, else the username (the
|
||||||
Users page keeps `@username` beside a display name). Logging in, the CLI and
|
Users page keeps `@username` beside a display name). Logging in, the CLI and
|
||||||
the Linux account always use the username.
|
the Linux account always use the username. No one can pose as a colleague: a
|
||||||
|
display name that reads as another account's display name or username — marks,
|
||||||
|
case, spaces, punctuation, a bracketed part like "(you)" and look-alike letters
|
||||||
|
from other alphabets ignored — is refused (`409 ENAMETAKEN`), whether the user
|
||||||
|
sets it in Settings → Account or an admin does on the Users page. A name an
|
||||||
|
account already has is kept on an unrelated save; rename an old clash from the
|
||||||
|
Users page.
|
||||||
|
|
||||||
Under S3 an admin's folder browser opens on their own home too; ticking
|
Under S3 an admin's folder browser opens on their own home too; ticking
|
||||||
**Server folder** opens the host account's home (`/home/<host>`), where a
|
**Server folder** opens the host account's home (`/home/<host>`), where a
|
||||||
@@ -549,6 +555,10 @@ agenthub accounts pin team-key mia --tool codex # pins work as for Claude
|
|||||||
is used only for a pane that runs as its developer (one Linux user per
|
is used only for a pane that runs as its developer (one Linux user per
|
||||||
account): AgentHub writes the account's `auth.json` into that developer's
|
account): AgentHub writes the account's `auth.json` into that developer's
|
||||||
`~/.codex` as them (0600) just before the pane starts.
|
`~/.codex` as them (0600) just before the pane starts.
|
||||||
|
- The pane runs `codex --no-daemon` (when that Codex lists the flag in
|
||||||
|
`--help`): Codex ≥ 0.164 otherwise hands a plain session to the home's
|
||||||
|
shared app-server daemon, which outlives every pane and keeps the login
|
||||||
|
it started with — the new `auth.json` would not be used.
|
||||||
- A host pane never gets one: its `CODEX_HOME` holds the host's own login.
|
- A host pane never gets one: its `CODEX_HOME` holds the host's own login.
|
||||||
- A developer who signed Codex in themselves keeps their own login.
|
- A developer who signed Codex in themselves keeps their own login.
|
||||||
- While a pane of theirs runs on a pool account, their next pane uses the
|
- While a pane of theirs runs on a pool account, their next pane uses the
|
||||||
@@ -726,7 +736,17 @@ An admin limits what each user may spend, in dollars, on the Users page
|
|||||||
every account change; each one re-applies at once (`sweep`).
|
every account change; each one re-applies at once (`sweep`).
|
||||||
|
|
||||||
Spend is the users page's own figure: the tokens of the sessions the user
|
Spend is the users page's own figure: the tokens of the sessions the user
|
||||||
OWNS through a workspace, priced by `calculateCost`. The Users table shows
|
OWNS, priced by `calculateCost`. A session is owned through its workspace;
|
||||||
|
one no workspace owns — a script's `claude -p` in a temp folder, a CLI in a
|
||||||
|
plain terminal — is owned by the user of the Linux account it ran as
|
||||||
|
(`sessions.account`, `server/lib/session-account.js`: the hook's peer uid,
|
||||||
|
else the session's folder): a member's account bills to that member, the
|
||||||
|
host's to the admin ticked **Owns this server's host account** in Edit user
|
||||||
|
(`PATCH /api/users/:id` `{hostAccount: true}`, at most one), or with none
|
||||||
|
ticked to the only admin. The same rule feeds the status bar's **Today**.
|
||||||
|
A process that drops the pane's environment also drops
|
||||||
|
`AGENTHUB_QUOTA_FILE`, so its prompts are not refused by the hook — but its
|
||||||
|
spend still counts, and stops the user's next session. The Users table shows
|
||||||
each user's spend against their quota; the user sees their own as **Quota N%**
|
each user's spend against their quota; the user sees their own as **Quota N%**
|
||||||
in the status bar (`GET /api/me/quota`).
|
in the status bar (`GET /api/me/quota`).
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -3,7 +3,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Agent Dashboard for Claude Code API
|
title: Agent Dashboard for Claude Code API
|
||||||
version: 2.8.30
|
version: 2.8.40
|
||||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||||
contact:
|
contact:
|
||||||
name: Nguyễn Ngọc Trí Vĩ
|
name: Nguyễn Ngọc Trí Vĩ
|
||||||
@@ -15410,6 +15410,9 @@ paths:
|
|||||||
type: boolean
|
type: boolean
|
||||||
enum:
|
enum:
|
||||||
- true
|
- true
|
||||||
|
hostAccount:
|
||||||
|
type: boolean
|
||||||
|
description: 'An admin owns the host''s Linux account: its sessions outside every workspace are billed to them (at most one; EBADROLE for a non-admin)'
|
||||||
quota:
|
quota:
|
||||||
$ref: '#/components/schemas/UserQuotaSetting'
|
$ref: '#/components/schemas/UserQuotaSetting'
|
||||||
currentPassword:
|
currentPassword:
|
||||||
@@ -19932,6 +19935,9 @@ paths:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
description: Files the switch wrote.
|
description: Files the switch wrote.
|
||||||
|
following:
|
||||||
|
type: boolean
|
||||||
|
description: 'Codex: a running app-server daemon was restarted, so open and new sessions use the new login.'
|
||||||
'400':
|
'400':
|
||||||
description: '`EBADNAME` / `EBADLABEL`.'
|
description: '`EBADNAME` / `EBADLABEL`.'
|
||||||
content:
|
content:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.36",
|
"version": "2.8.47",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.36",
|
"version": "2.8.47",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.36",
|
"version": "2.8.47",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -665,6 +665,128 @@ describe("skills from files or written", () => {
|
|||||||
assert.equal(bad.body.error.code, "EBADNAME");
|
assert.equal(bad.body.error.code, "EBADNAME");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updates the uploader's same-named skill instead of adding a twin", async () => {
|
||||||
|
const md = (v) => `---\nname: sg-again\ndescription: version ${v}\n---\nBody ${v}.\n`;
|
||||||
|
const first = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(1)]]);
|
||||||
|
assert.equal(first.status, 201, JSON.stringify(first.body));
|
||||||
|
const id = first.body.items[0].id;
|
||||||
|
const again = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(2)]]);
|
||||||
|
assert.equal(again.status, 201, JSON.stringify(again.body));
|
||||||
|
assert.equal(again.body.items[0].id, id);
|
||||||
|
assert.equal(again.body.items[0].replaced, true);
|
||||||
|
assert.equal(again.body.items[0].changed, true);
|
||||||
|
assert.equal(again.body.items[0].description, "version 2");
|
||||||
|
assert.match(snapText(again.body.items[0]), /Body 2\./);
|
||||||
|
const rows = db
|
||||||
|
.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill' AND source = 'sg-again'")
|
||||||
|
.get();
|
||||||
|
assert.equal(rows.n, 1);
|
||||||
|
const same = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(2)]]);
|
||||||
|
assert.equal(same.body.items[0].changed, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dates a skill by its content, and tells each install's copy from the newest", async () => {
|
||||||
|
const md = (v) => `---\nname: sg-state\ndescription: d\n---\nState ${v}.\n`;
|
||||||
|
const first = await upload("/api/artifacts/skill/upload", [["sg-state/SKILL.md", md(1)]]);
|
||||||
|
const id = first.body.items[0].id;
|
||||||
|
const at1 = first.body.items[0].versionAt;
|
||||||
|
assert.ok(at1);
|
||||||
|
const states = async () =>
|
||||||
|
(await request("GET", `/api/artifacts/${id}/installs`)).body.items.map((i) => i.state);
|
||||||
|
assert.equal((await request("POST", `/api/artifacts/${id}/install`, {})).status, 200);
|
||||||
|
assert.deepEqual(await states(), ["latest"]);
|
||||||
|
|
||||||
|
// A rename is no new version; new content is.
|
||||||
|
const renamed = await request("PATCH", `/api/artifacts/${id}`, { title: "State" });
|
||||||
|
assert.equal(renamed.body.item.versionAt, at1);
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
const next = await upload("/api/artifacts/skill/upload", [["sg-state/SKILL.md", md(2)]]);
|
||||||
|
assert.ok(next.body.items[0].versionAt > at1);
|
||||||
|
assert.deepEqual(await states(), ["outdated"]);
|
||||||
|
|
||||||
|
fs.rmSync(path.join(process.env.CLAUDE_HOME, "skills", "sg-state"), { recursive: true });
|
||||||
|
assert.deepEqual(await states(), ["removed"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Share again: an uploaded skill asks for its folder, which replaces that row", async () => {
|
||||||
|
const md = (v) => `---\nname: sg-up\ndescription: d\n---\nUp ${v}.\n`;
|
||||||
|
const first = await upload("/api/artifacts/skill/upload", [["sg-up/SKILL.md", md(1)]]);
|
||||||
|
const id = first.body.items[0].id;
|
||||||
|
const no = await request("POST", `/api/artifacts/${id}/reshare`);
|
||||||
|
assert.equal(no.status, 409);
|
||||||
|
assert.equal(no.body.error.code, "ENOSOURCE");
|
||||||
|
// The folder picked again, under any folder name: onto THIS row.
|
||||||
|
const r = await upload("/api/artifacts/skill/upload", [["renamed/SKILL.md", md(2)]], {
|
||||||
|
id: String(id),
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||||
|
assert.equal(r.body.items[0].id, id);
|
||||||
|
assert.equal(r.body.items[0].source, "sg-up");
|
||||||
|
assert.match(snapText(r.body.items[0]), /^---\nname: sg-up\n[\s\S]*Up 2\./);
|
||||||
|
const two = await upload(
|
||||||
|
"/api/artifacts/skill/upload",
|
||||||
|
[
|
||||||
|
["p/a/SKILL.md", "---\nname: a\ndescription: d\n---\n"],
|
||||||
|
["p/b/SKILL.md", "---\nname: b\ndescription: d\n---\n"],
|
||||||
|
],
|
||||||
|
{ id: String(id) }
|
||||||
|
);
|
||||||
|
assert.equal(two.body.error.code, "EBADUPLOAD");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Share again takes the owner's folder, else the skill of an installed plugin", async () => {
|
||||||
|
const home = process.env.CLAUDE_HOME;
|
||||||
|
// An uploaded skill whose name a user-scope plugin also ships.
|
||||||
|
const first = await upload("/api/artifacts/skill/upload", [
|
||||||
|
["sg-plug/SKILL.md", "---\nname: sg-plug\ndescription: d\n---\nOld.\n"],
|
||||||
|
]);
|
||||||
|
const id = first.body.items[0].id;
|
||||||
|
const writePlugin = (ver, text) => {
|
||||||
|
const at = path.join(home, "plugins", "cache", "m", "p", ver);
|
||||||
|
fs.mkdirSync(path.join(at, "skills", "sg-plug"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(at, "skills", "sg-plug", "SKILL.md"), text);
|
||||||
|
return at;
|
||||||
|
};
|
||||||
|
const v1 = writePlugin("1.0.0", "---\nname: sg-plug\ndescription: d\n---\nPlugin one.\n");
|
||||||
|
const v2 = writePlugin("2.0.0", "---\nname: sg-plug\ndescription: d\n---\nPlugin two.\n");
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(home, "plugins", "installed_plugins.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
plugins: {
|
||||||
|
"p@m": [{ scope: "user", installPath: v2, lastUpdated: "2026-10-30T00:00:00Z" }],
|
||||||
|
"old@m": [{ scope: "user", installPath: v1, lastUpdated: "2026-01-01T00:00:00Z" }],
|
||||||
|
"proj@m": [
|
||||||
|
{ scope: "project", installPath: "/etc", lastUpdated: "2027-01-01T00:00:00Z" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const r = await request("POST", `/api/artifacts/${id}/reshare`);
|
||||||
|
assert.equal(r.status, 200, r.text);
|
||||||
|
assert.equal(r.body.changed, true);
|
||||||
|
assert.match(snapText(r.body.item), /Plugin two\./);
|
||||||
|
assert.equal((await request("POST", `/api/artifacts/${id}/reshare`)).body.changed, false);
|
||||||
|
|
||||||
|
// The owner's own ~/.claude/skills folder outranks a plugin's copy.
|
||||||
|
const own = path.join(home, "skills", "sg-plug");
|
||||||
|
fs.mkdirSync(own, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(own, "SKILL.md"),
|
||||||
|
"---\nname: sg-plug\ndescription: d\n---\nMine.\n"
|
||||||
|
);
|
||||||
|
const mine = await request("POST", `/api/artifacts/${id}/reshare`);
|
||||||
|
assert.equal(mine.body.changed, true);
|
||||||
|
assert.match(snapText(mine.body.item), /Mine\./);
|
||||||
|
// Recorded as the folder's state: the 15 s sync does not take it twice.
|
||||||
|
assert.deepEqual(
|
||||||
|
artifacts.syncSkillSources({ force: true }).filter((c) => c.id === id),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
fs.rmSync(path.join(home, "plugins"), { recursive: true, force: true });
|
||||||
|
fs.rmSync(own, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps an uploaded file as a snapshot, by its own base name", async () => {
|
it("keeps an uploaded file as a snapshot, by its own base name", async () => {
|
||||||
const r = await upload("/api/artifacts/file/upload", [["report.html", "<h1>x</h1>"]], {
|
const r = await upload("/api/artifacts/file/upload", [["report.html", "<h1>x</h1>"]], {
|
||||||
title: "Report",
|
title: "Report",
|
||||||
@@ -859,6 +981,23 @@ describe("outputs captured at a session's stop", () => {
|
|||||||
);
|
);
|
||||||
assert.deepEqual(await artifacts.captureOutputs("no-such-session"), []);
|
assert.deepEqual(await artifacts.captureOutputs("no-such-session"), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves a web app's own pages alone and still takes a standalone HTML report", async () => {
|
||||||
|
const put = (rel, text = "<p>x</p>") => {
|
||||||
|
fs.mkdirSync(path.dirname(path.join(ws, rel)), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(ws, rel), text);
|
||||||
|
const soon = new Date(Date.now() + 5 * 60 * 1000);
|
||||||
|
fs.utimesSync(path.join(ws, rel), soon, soon);
|
||||||
|
};
|
||||||
|
put("app/index.html");
|
||||||
|
put("app/www/page.html");
|
||||||
|
put("widget/view.html");
|
||||||
|
put("widget/view.js", "x()");
|
||||||
|
put("summary-report.html");
|
||||||
|
put("out/q3.html");
|
||||||
|
const got = (await artifacts.captureOutputs("s1")).map((r) => r.source).sort();
|
||||||
|
assert.deepEqual(got, ["out/q3.html", "summary-report.html"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("unowned rows", () => {
|
describe("unowned rows", () => {
|
||||||
|
|||||||
@@ -243,6 +243,29 @@ describe("codex pool: spawn", () => {
|
|||||||
assert.equal(row.account, "c");
|
assert.equal(row.account, "c");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runs a ChatGPT pane without the shared daemon, only when its Codex has the flag", () => {
|
||||||
|
// The home's app-server daemon keeps the login it started with: a placed
|
||||||
|
// login reaches only a pane that runs its own app server.
|
||||||
|
const fakeCodex = (name, help) => {
|
||||||
|
const file = path.join(TMP, name);
|
||||||
|
fs.writeFileSync(file, `#!/bin/sh\necho '${help}'\n`, { mode: 0o755 });
|
||||||
|
return file;
|
||||||
|
};
|
||||||
|
const prev = process.env.AGENTHUB_CODEX_BIN;
|
||||||
|
try {
|
||||||
|
pool.addAccount("c", { auth: AUTH("acct-c") });
|
||||||
|
process.env.AGENTHUB_CODEX_BIN = fakeCodex("codex-new", " --no-daemon Run without");
|
||||||
|
assert.deepEqual(pool.forSpawn(MIA).argv, ["--no-daemon"]);
|
||||||
|
process.env.AGENTHUB_CODEX_BIN = fakeCodex("codex-old", " --oss Use a local model");
|
||||||
|
assert.deepEqual(pool.forSpawn(MIA).argv, [], "an older Codex would refuse the flag");
|
||||||
|
process.env.AGENTHUB_CODEX_BIN = path.join(TMP, "no-such-codex");
|
||||||
|
assert.deepEqual(pool.forSpawn(MIA).argv, []);
|
||||||
|
} finally {
|
||||||
|
if (prev === undefined) delete process.env.AGENTHUB_CODEX_BIN;
|
||||||
|
else process.env.AGENTHUB_CODEX_BIN = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("never replaces a member's own login, and stays on the account their live pane uses", () => {
|
it("never replaces a member's own login, and stays on the account their live pane uses", () => {
|
||||||
pool.addAccount("c", { auth: AUTH("acct-c") });
|
pool.addAccount("c", { auth: AUTH("acct-c") });
|
||||||
pool.addAccount("d", { auth: AUTH("acct-d") });
|
pool.addAccount("d", { auth: AUTH("acct-d") });
|
||||||
@@ -272,7 +295,10 @@ describe("codex pool: spawn", () => {
|
|||||||
fs.writeFileSync(MIA_AUTH, JSON.stringify(own));
|
fs.writeFileSync(MIA_AUTH, JSON.stringify(own));
|
||||||
assert.equal(pool.pickAccount(MIA), null);
|
assert.equal(pool.pickAccount(MIA), null);
|
||||||
pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" });
|
pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" });
|
||||||
assert.equal(JSON.parse(fs.readFileSync(MIA_AUTH, "utf8")).tokens.refresh_token, "rt-mias-seat");
|
assert.equal(
|
||||||
|
JSON.parse(fs.readFileSync(MIA_AUTH, "utf8")).tokens.refresh_token,
|
||||||
|
"rt-mias-seat"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("copies the newest auth.json back into the pool", () => {
|
it("copies the newest auth.json back into the pool", () => {
|
||||||
|
|||||||
@@ -473,6 +473,55 @@ describe("reads larger than the git() buffer report truncated, not missing", ()
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("a page with megabyte-long lines (inlined base64) keeps every hunk", () => {
|
||||||
|
const blob = (c) => c.repeat(3 * 1024 * 1024); // one 3 MB line
|
||||||
|
|
||||||
|
it("cuts the long rows down and still shows the hunks after them", async () => {
|
||||||
|
const body = (mid, end) =>
|
||||||
|
[
|
||||||
|
"<h1>title</h1>",
|
||||||
|
...Array.from({ length: 40 }, (_, i) => `row ${i}`),
|
||||||
|
mid,
|
||||||
|
...Array.from({ length: 40 }, (_, i) => `tail ${i}`),
|
||||||
|
end,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
write(LANE, "deck.html", body(`<video src="${blob("A")}">`, "<p>old end</p>"));
|
||||||
|
g(LANE, "add", "-A");
|
||||||
|
g(LANE, "commit", "-qm", "deck");
|
||||||
|
write(
|
||||||
|
LANE,
|
||||||
|
"deck.html",
|
||||||
|
body(`<video src="${blob("B")}">`, "<p>new end</p>").replace(
|
||||||
|
"<h1>title</h1>",
|
||||||
|
"<h1>new title</h1>"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const out = await collectChanges(LANE, { range: "work", file: "deck.html" });
|
||||||
|
const f = out.files[0];
|
||||||
|
assert.equal(f.truncated, undefined);
|
||||||
|
assert.equal(out.truncated, false);
|
||||||
|
const lines = f.hunks.flatMap((h) => h.lines);
|
||||||
|
const long = lines.filter((l) => l.elided);
|
||||||
|
assert.equal(long.length, 2);
|
||||||
|
for (const l of long) {
|
||||||
|
assert.ok(l.text.length < 2000);
|
||||||
|
assert.equal(l.text.length + l.elided, '<video src="'.length + 3 * 1024 * 1024 + 2);
|
||||||
|
}
|
||||||
|
// The change AFTER the 3 MB rows survives: the cap no longer eats it.
|
||||||
|
assert.ok(lines.some((l) => l.type === "add" && l.text === "<p>new end</p>"));
|
||||||
|
assert.ok(lines.some((l) => l.type === "add" && l.text === "<h1>new title</h1>"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves ordinary rows whole", async () => {
|
||||||
|
write(LANE, "a.txt", "one\nTWO\n");
|
||||||
|
const out = await collectChanges(LANE, { range: "work", file: "a.txt" });
|
||||||
|
const lines = out.files[0].hunks.flatMap((h) => h.lines);
|
||||||
|
assert.ok(lines.every((l) => l.elided === undefined));
|
||||||
|
assert.ok(lines.some((l) => l.text === "TWO"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("loop 2: secret file at any depth, symlinked .claude", () => {
|
describe("loop 2: secret file at any depth, symlinked .claude", () => {
|
||||||
const KEY = '{"env":{"ANTHROPIC_API_KEY":"sk-deep"}}';
|
const KEY = '{"env":{"ANTHROPIC_API_KEY":"sk-deep"}}';
|
||||||
|
|
||||||
|
|||||||
@@ -203,18 +203,21 @@ describe("collectChanges — work range", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still returns content when a huge file has no newline to cut on", async () => {
|
it("still returns content when a huge file is one line", async () => {
|
||||||
// A minified bundle is ONE line. Cutting the patch at the last newline
|
// A minified bundle is ONE line. It used to be cut at the per-file cap
|
||||||
// inside the cap would land at index -1 here and hand back an empty
|
// (and, cut at the last newline, could come back empty — a file changed
|
||||||
// patch — a file changed by a megabyte rendering as "no textual change".
|
// by a megabyte rendering as "no textual change"); now the row itself is
|
||||||
|
// cut down and says how much it hides, and nothing is truncated.
|
||||||
const { MAX_FILE_BYTES } = require("../lib/git-diff");
|
const { MAX_FILE_BYTES } = require("../lib/git-diff");
|
||||||
write(LANE, "min.js", `${"x".repeat(MAX_FILE_BYTES + 5000)}\n`);
|
write(LANE, "min.js", `${"x".repeat(MAX_FILE_BYTES + 5000)}\n`);
|
||||||
const out = await collectChanges(LANE, { range: "work", file: "min.js" });
|
const out = await collectChanges(LANE, { range: "work", file: "min.js" });
|
||||||
const entry = out.files[0];
|
const entry = out.files[0];
|
||||||
assert.equal(entry.truncated, true);
|
assert.equal(entry.truncated, undefined);
|
||||||
assert.equal(out.truncated, true);
|
assert.equal(out.truncated, false);
|
||||||
assert.ok(entry.hunks.length > 0, "a truncated patch still carries its hunk");
|
assert.ok(entry.hunks.length > 0, "the patch still carries its hunk");
|
||||||
assert.ok(entry.hunks[0].lines[0].text.startsWith("xxx"));
|
const row = entry.hunks[0].lines.find((l) => l.type === "add");
|
||||||
|
assert.ok(row.text.startsWith("xxx"));
|
||||||
|
assert.equal(row.text.length + row.elided, MAX_FILE_BYTES + 5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports a binary file without trying to diff it", async () => {
|
it("reports a binary file without trying to diff it", async () => {
|
||||||
@@ -848,6 +851,51 @@ describe("runGitAction — one change block inside a hunk (issue #50)", () => {
|
|||||||
assert.equal(fs.readFileSync(path.join(LANE, "tail.txt"), "utf8"), "l1\nl2\nl3\nl4\nl5\nl6");
|
assert.equal(fs.readFileSync(path.join(LANE, "tail.txt"), "utf8"), "l1\nl2\nl3\nl4\nl5\nl6");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("discards only the ticked blocks and whole files, nothing unticked (issue #87)", async () => {
|
||||||
|
const edited = threeBlocks();
|
||||||
|
write(LANE, "keep.txt", "untouched by discard\n");
|
||||||
|
write(LANE, "drop.txt", "gone\n");
|
||||||
|
const [hunk] = await hunkOf();
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
runGitAction(LANE, "discard", {
|
||||||
|
paths: ["drop.txt"],
|
||||||
|
hunks: { "near.txt": [{ hunk: hunk.header, blocks: [0, 2] }] },
|
||||||
|
}),
|
||||||
|
{ code: "ECONFIRM" }
|
||||||
|
);
|
||||||
|
await runGitAction(LANE, "discard", {
|
||||||
|
paths: ["drop.txt"],
|
||||||
|
hunks: { "near.txt": [{ hunk: hunk.header, blocks: [0, 2] }] },
|
||||||
|
confirm: true,
|
||||||
|
});
|
||||||
|
const want = [...edited];
|
||||||
|
want.splice(11, 0, "line 11");
|
||||||
|
want.splice(3, 2, "line 4", "line 5");
|
||||||
|
assert.deepEqual(read(), [...want, ""]);
|
||||||
|
assert.equal(fs.existsSync(path.join(LANE, "drop.txt")), false);
|
||||||
|
assert.equal(fs.readFileSync(path.join(LANE, "keep.txt"), "utf8"), "untouched by discard\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards ticked blocks alone, and refuses a stale one with nothing undone", async () => {
|
||||||
|
const edited = threeBlocks();
|
||||||
|
const [hunk] = await hunkOf();
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
runGitAction(LANE, "discard", {
|
||||||
|
hunks: { "near.txt": [{ hunk: "@@ -1,2 +1,2 @@", blocks: [0] }] },
|
||||||
|
confirm: true,
|
||||||
|
}),
|
||||||
|
{ code: "EHUNKGONE" }
|
||||||
|
);
|
||||||
|
assert.deepEqual(read(), [...edited, ""]);
|
||||||
|
await runGitAction(LANE, "discard", {
|
||||||
|
hunks: { "near.txt": [{ hunk: hunk.header, blocks: [1] }] },
|
||||||
|
confirm: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(read(), [...edited.filter((l) => l !== "INSERTED"), ""]);
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses a block the hunk does not have", async () => {
|
it("refuses a block the hunk does not have", async () => {
|
||||||
threeBlocks();
|
threeBlocks();
|
||||||
const [hunk] = await hunkOf();
|
const [hunk] = await hunkOf();
|
||||||
|
|||||||
@@ -222,6 +222,26 @@ describe("Settings → Account (/api/me)", () => {
|
|||||||
assert.equal(bad.body.error.code, "EBADEMAIL");
|
assert.equal(bad.body.error.code, "EBADEMAIL");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refuses a display name that reads as another account's (ENAMETAKEN)", async () => {
|
||||||
|
assert.equal(auth.nameKey("Nguyễn Ngọc Trí Vĩ (you)"), auth.nameKey("nguyen.ngoc-tri VI"));
|
||||||
|
assert.equal(auth.nameKey("Đào"), "dao");
|
||||||
|
// Cyrillic о and а in place of Latin letters.
|
||||||
|
assert.equal(auth.nameKey("Mіа Trоn"), auth.nameKey("Mia Tron"));
|
||||||
|
const leo = await login("leo", "member-pass-2");
|
||||||
|
// mia's display name is "Mia Tran"; boss has none, only a username.
|
||||||
|
for (const name of ["MIA TRẦN (you)", "Mia\u200b Tran", "mia", "Bóss"]) {
|
||||||
|
const r = await call("PATCH", "/api/me/account", { cookie: leo, body: { displayName: name } });
|
||||||
|
assert.equal(r.status, 409, name);
|
||||||
|
assert.equal(r.body.error.code, "ENAMETAKEN");
|
||||||
|
}
|
||||||
|
assert.equal(auth.findUser("leo").display_name, "Leo N");
|
||||||
|
// Keeping one's own name, or a name of one's own, still works.
|
||||||
|
for (const name of ["Leo N", "Leo Nguyen"]) {
|
||||||
|
const r = await call("PATCH", "/api/me/account", { cookie: leo, body: { displayName: name } });
|
||||||
|
assert.equal(r.status, 200, name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("changes its own password only with the old one, keeping this login", async () => {
|
it("changes its own password only with the old one, keeping this login", async () => {
|
||||||
const leo = await login("leo", "member-pass-2");
|
const leo = await login("leo", "member-pass-2");
|
||||||
const other = await login("leo", "member-pass-2");
|
const other = await login("leo", "member-pass-2");
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* @file session-account.test.js
|
||||||
|
* @description A session no workspace owns is billed to the user of the Linux
|
||||||
|
* account it ran as (server/lib/session-account.js): a member's account to
|
||||||
|
* that member, the host's to the admin marked as its owner (or the only
|
||||||
|
* admin). A workspace's ownership always wins, a pushed session never gets an
|
||||||
|
* account, and the hook peer is stamped once.
|
||||||
|
* @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(), "session-account-test-"));
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||||
|
|
||||||
|
const { db } = require("../db");
|
||||||
|
const sa = require("../lib/session-account");
|
||||||
|
const scope = require("../lib/scope");
|
||||||
|
const { OWNED_SESSIONS } = require("../routes/users");
|
||||||
|
|
||||||
|
const own = (id) => {
|
||||||
|
const w = scope.ownSessionWhere({ user: { id } });
|
||||||
|
return db
|
||||||
|
.prepare(`SELECT id FROM sessions WHERE ${w.where} ORDER BY id`)
|
||||||
|
.all(...w.params)
|
||||||
|
.map((r) => r.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
const user = db.prepare(
|
||||||
|
"INSERT INTO users (id, username, role, password_hash) VALUES (?, ?, ?, 'x')"
|
||||||
|
);
|
||||||
|
user.run(1, "boss", "admin");
|
||||||
|
user.run(2, "dev", "developer");
|
||||||
|
db.prepare("INSERT INTO lanes (id, cwd, tool, owner_id) VALUES (1, '/w/dev', 'claude', 2)").run();
|
||||||
|
const s = db.prepare(
|
||||||
|
"INSERT INTO sessions (id, name, status, cwd, lane_id, account, source) VALUES (?, ?, 'active', '/w', ?, ?, ?)"
|
||||||
|
);
|
||||||
|
s.run("host-free", "eval", null, "", "local");
|
||||||
|
s.run("dev-free", "eval", null, "dev", "local");
|
||||||
|
s.run("host-in-dev-lane", "lane", 1, "", "local");
|
||||||
|
s.run("unknown", "old", null, null, "local");
|
||||||
|
s.run("gone-member", "x", null, "ghost", "local");
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
describe("session-account", () => {
|
||||||
|
it("bills a member's account to that member and the host's to the only admin", () => {
|
||||||
|
assert.equal(sa.hostOwner(), 1);
|
||||||
|
assert.deepEqual(own(1), ["host-free"]);
|
||||||
|
assert.deepEqual(own(2), ["dev-free", "host-in-dev-lane"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the workspace's owner win over the account", () => {
|
||||||
|
assert.ok(!own(1).includes("host-in-dev-lane"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bills nobody for the host when two admins and none is marked, then the marked one", () => {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO users (id, username, role, password_hash) VALUES (3, 'boss2', 'admin', 'x')"
|
||||||
|
).run();
|
||||||
|
assert.equal(sa.hostOwner(), null);
|
||||||
|
assert.deepEqual(own(1), []);
|
||||||
|
sa.setHostOwner(3);
|
||||||
|
assert.equal(sa.hostOwner(), 3);
|
||||||
|
assert.deepEqual(own(3), ["host-free"]);
|
||||||
|
sa.setHostOwner(1);
|
||||||
|
assert.deepEqual(own(3), []);
|
||||||
|
assert.deepEqual(own(1), ["host-free"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists account-billed sessions in OWNED_SESSIONS (Users page, spend quota)", () => {
|
||||||
|
const rows = db.prepare(OWNED_SESSIONS).all();
|
||||||
|
const by = Object.fromEntries(rows.map((r) => [r.session_id, r.uid]));
|
||||||
|
assert.equal(by["host-free"], 1);
|
||||||
|
assert.equal(by["dev-free"], 2);
|
||||||
|
assert.equal(by["host-in-dev-lane"], 2);
|
||||||
|
assert.equal(by.unknown, undefined);
|
||||||
|
assert.equal(by["gone-member"], undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stamps the hook peer once and never a pushed session", () => {
|
||||||
|
db.prepare("INSERT INTO sessions (id, name, status, cwd) VALUES ('fresh', 'f', 'active', '/w')").run();
|
||||||
|
db.prepare("INSERT INTO sessions (id, name, status, cwd) VALUES ('pushed', 'p', 'active', '/w')").run();
|
||||||
|
sa.stampFromPeer("fresh", { kind: "member", name: "dev", home: "/srv/u/dev" });
|
||||||
|
sa.stampFromPeer("fresh", { kind: "host" });
|
||||||
|
sa.stampFromPeer("pushed", { kind: "host" }, "push:laptop");
|
||||||
|
sa.stampFromPeer("pushed", { kind: "refused" });
|
||||||
|
const acct = (id) => db.prepare("SELECT account FROM sessions WHERE id = ?").get(id).account;
|
||||||
|
assert.equal(acct("fresh"), "dev");
|
||||||
|
assert.equal(acct("pushed"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a folder's account to sessions no hook stamped, skipping pushed ones", () => {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO sessions (id, name, status, cwd, source) VALUES ('remote', 'r', 'active', '/w', 'push:laptop')"
|
||||||
|
).run();
|
||||||
|
sa.stampFromFolders();
|
||||||
|
const acct = (id) => db.prepare("SELECT account FROM sessions WHERE id = ?").get(id).account;
|
||||||
|
// Outside S3 every folder is the host's.
|
||||||
|
assert.equal(acct("unknown"), "");
|
||||||
|
assert.equal(acct("pushed"), "");
|
||||||
|
assert.equal(acct("remote"), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -97,6 +97,33 @@ describe("tool-profiles: codex", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restarts a running Codex daemon so open sessions follow; none running, nothing to restart", async () => {
|
||||||
|
login("acct-a", "alice@example.com");
|
||||||
|
profiles.saveProfile("codex");
|
||||||
|
login("acct-b", "bob@example.com");
|
||||||
|
profiles.saveProfile("codex");
|
||||||
|
assert.equal(profiles.activateProfile("codex", "alice").following, false);
|
||||||
|
|
||||||
|
const log = path.join(ROOT, "codex-argv.log");
|
||||||
|
const bin = path.join(ROOT, "fake-codex.sh");
|
||||||
|
fs.writeFileSync(bin, `#!/bin/sh\necho "$CODEX_HOME $*" >> '${log}'\n`, { mode: 0o755 });
|
||||||
|
const sock = path.join(process.env.CODEX_HOME, "app-server-control", "app-server-control.sock");
|
||||||
|
fs.mkdirSync(path.dirname(sock), { recursive: true });
|
||||||
|
fs.writeFileSync(sock, "");
|
||||||
|
process.env.AGENTHUB_CODEX_BIN = bin;
|
||||||
|
try {
|
||||||
|
assert.equal(profiles.activateProfile("codex", "bob").following, true);
|
||||||
|
for (let i = 0; i < 50 && !fs.existsSync(log); i++)
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(log, "utf8").trim(),
|
||||||
|
`${process.env.CODEX_HOME} app-server daemon restart`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
delete process.env.AGENTHUB_CODEX_BIN;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses to switch away from an unsaved login", () => {
|
it("refuses to switch away from an unsaved login", () => {
|
||||||
login("acct-a", "alice@example.com");
|
login("acct-a", "alice@example.com");
|
||||||
profiles.saveProfile("codex");
|
profiles.saveProfile("codex");
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ describe("GET /api/sessions/:id/stats tool totals", () => {
|
|||||||
assert.equal(body.tool_call_count, 2);
|
assert.equal(body.tool_call_count, 2);
|
||||||
assert.equal(body.distinct_tool_count, 1);
|
assert.equal(body.distinct_tool_count, 1);
|
||||||
});
|
});
|
||||||
|
it("counts each call once in tools_used, not its Pre and Post events", async () => {
|
||||||
|
const one = (await get("/api/sessions/ua-1/stats")).body.tools_used;
|
||||||
|
assert.ok(one.every((t) => t.count === 1));
|
||||||
|
const two = (await get("/api/sessions/ua-2/stats")).body.tools_used;
|
||||||
|
assert.deepEqual(two, [{ tool_name: "Read", count: 2 }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /api/events labels", () => {
|
describe("GET /api/events labels", () => {
|
||||||
|
|||||||
@@ -973,11 +973,16 @@ for (const [col, ddl] of [
|
|||||||
["suggested_owner_id", "suggested_owner_id INTEGER"],
|
["suggested_owner_id", "suggested_owner_id INTEGER"],
|
||||||
["version", "version TEXT"],
|
["version", "version TEXT"],
|
||||||
["source_hash", "source_hash TEXT"],
|
["source_hash", "source_hash TEXT"],
|
||||||
|
["version_at", "version_at TEXT"],
|
||||||
]) {
|
]) {
|
||||||
try {
|
try {
|
||||||
db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get();
|
db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get();
|
||||||
} catch {
|
} catch {
|
||||||
db.prepare(`ALTER TABLE artifacts ADD COLUMN ${ddl}`).run();
|
db.prepare(`ALTER TABLE artifacts ADD COLUMN ${ddl}`).run();
|
||||||
|
// `version_at`: when a row's CONTENT last changed (a new snapshot) — a
|
||||||
|
// rename or a share toggle moves `updated_at` but not this. An existing
|
||||||
|
// row starts from its `updated_at`, the best record there is.
|
||||||
|
if (col === "version_at") db.prepare("UPDATE artifacts SET version_at = updated_at").run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1445,6 +1450,16 @@ try {
|
|||||||
db.prepare("ALTER TABLE sessions ADD COLUMN pinned_at TEXT").run();
|
db.prepare("ALTER TABLE sessions ADD COLUMN pinned_at TEXT").run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migrate: add `account` to sessions — the Linux account a session ran as
|
||||||
|
// (server/lib/session-account.js): "" = the host's, else the S3 member's name;
|
||||||
|
// NULL = not known yet. A session no workspace owns is billed to the user of
|
||||||
|
// that account (server/lib/scope.js), so a script's `claude -p` still counts.
|
||||||
|
try {
|
||||||
|
db.prepare("SELECT account FROM sessions LIMIT 1").get();
|
||||||
|
} catch {
|
||||||
|
db.prepare("ALTER TABLE sessions ADD COLUMN account TEXT").run();
|
||||||
|
}
|
||||||
|
|
||||||
// How far each Codex rollout journal has been read (server/lib/codex-usage.js).
|
// How far each Codex rollout journal has been read (server/lib/codex-usage.js).
|
||||||
// In the DB, not a side file, so a session the user deleted stays deleted
|
// In the DB, not a side file, so a session the user deleted stays deleted
|
||||||
// until Codex writes to its journal again, while a wiped DB re-imports all.
|
// until Codex writes to its journal again, while a wiped DB re-imports all.
|
||||||
@@ -1952,6 +1967,9 @@ for (const col of ["display_name", "email", "avatar_at", "avatar_link"]) {
|
|||||||
for (const [col, type] of [
|
for (const [col, type] of [
|
||||||
["last_login_at", "TEXT"],
|
["last_login_at", "TEXT"],
|
||||||
["must_change_password", "INTEGER NOT NULL DEFAULT 0"],
|
["must_change_password", "INTEGER NOT NULL DEFAULT 0"],
|
||||||
|
// The admin who owns the HOST's Linux account: its sessions outside every
|
||||||
|
// workspace are billed to them (server/lib/session-account.js). At most one.
|
||||||
|
["host_account", "INTEGER NOT NULL DEFAULT 0"],
|
||||||
]) {
|
]) {
|
||||||
try {
|
try {
|
||||||
db.prepare(`SELECT ${col} FROM users LIMIT 1`).get();
|
db.prepare(`SELECT ${col} FROM users LIMIT 1`).get();
|
||||||
@@ -2547,10 +2565,16 @@ const stmts = {
|
|||||||
FROM events
|
FROM events
|
||||||
WHERE session_id = ? AND tool_name IS NOT NULL
|
WHERE session_id = ? AND tool_name IS NOT NULL
|
||||||
`),
|
`),
|
||||||
|
// One row per call, like sessionToolCallTotals: a call is its PreToolUse,
|
||||||
|
// or its PostToolUse in a session that has none (imported). Counting every
|
||||||
|
// event with a tool_name counted each call twice.
|
||||||
sessionToolUsageCounts: db.prepare(`
|
sessionToolUsageCounts: db.prepare(`
|
||||||
SELECT tool_name, COUNT(*) as count
|
SELECT tool_name, COUNT(*) as count
|
||||||
FROM events
|
FROM events
|
||||||
WHERE session_id = ? AND tool_name IS NOT NULL
|
WHERE session_id = ? AND tool_name IS NOT NULL
|
||||||
|
AND event_type = CASE
|
||||||
|
WHEN EXISTS (SELECT 1 FROM events WHERE session_id = ? AND event_type = 'PreToolUse')
|
||||||
|
THEN 'PreToolUse' ELSE 'PostToolUse' END
|
||||||
GROUP BY tool_name
|
GROUP BY tool_name
|
||||||
ORDER BY count DESC
|
ORDER BY count DESC
|
||||||
LIMIT 15
|
LIMIT 15
|
||||||
|
|||||||
+4
-1
@@ -873,7 +873,9 @@ function startBackgroundServices() {
|
|||||||
if (poolTimer.unref) poolTimer.unref();
|
if (poolTimer.unref) poolTimer.unref();
|
||||||
}
|
}
|
||||||
// Server mode's spend quotas (user-quota.js): who is over theirs now, the
|
// Server mode's spend quotas (user-quota.js): who is over theirs now, the
|
||||||
// flag files their hooks read, the panes no hook can stop. Off with
|
// flag files their hooks read, the panes no hook can stop — after giving
|
||||||
|
// the sessions no hook made their Linux account (session-account.js), so a
|
||||||
|
// poller's session is billed to someone too. Off with
|
||||||
// DASHBOARD_QUOTA_SWEEP_MS=0; a no-op in local mode.
|
// DASHBOARD_QUOTA_SWEEP_MS=0; a no-op in local mode.
|
||||||
const QUOTA_SWEEP_MS = Number(process.env.DASHBOARD_QUOTA_SWEEP_MS ?? 60_000);
|
const QUOTA_SWEEP_MS = Number(process.env.DASHBOARD_QUOTA_SWEEP_MS ?? 60_000);
|
||||||
if (
|
if (
|
||||||
@@ -883,6 +885,7 @@ function startBackgroundServices() {
|
|||||||
) {
|
) {
|
||||||
const quotaSweep = () => {
|
const quotaSweep = () => {
|
||||||
try {
|
try {
|
||||||
|
require("./lib/session-account").stampFromFolders();
|
||||||
require("./lib/user-quota").sweep();
|
require("./lib/user-quota").sweep();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("user quota sweep failed:", err.message);
|
console.warn("user quota sweep failed:", err.message);
|
||||||
|
|||||||
+222
-16
@@ -173,6 +173,8 @@ function view(row, req, owners = null) {
|
|||||||
suggestedForMe: !!req?.user && row.owner_id == null && row.suggested_owner_id === req.user.id,
|
suggestedForMe: !!req?.user && row.owner_id == null && row.suggested_owner_id === req.user.id,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
|
// When its content last changed (a new snapshot): a page or plugin never does.
|
||||||
|
versionAt: row.version_at || row.created_at,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,13 +551,38 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
|
|||||||
const OUTPUT_EXT = /\.(pptx|docx|xlsx|pdf|html?)$/i;
|
const OUTPUT_EXT = /\.(pptx|docx|xlsx|pdf|html?)$/i;
|
||||||
/** Most files one stop captures, so a script that writes hundreds stays bounded. */
|
/** Most files one stop captures, so a script that writes hundreds stays bounded. */
|
||||||
const OUTPUT_MAX = 20;
|
const OUTPUT_MAX = 20;
|
||||||
|
/** Folders that hold a site's own pages, never a report someone reads. */
|
||||||
|
const SITE_DIR_RE = /^(www|public|dist|build|src|static|templates|assets|site|_site)$/i;
|
||||||
|
/** What sits next to a site's page: its scripts and styles. */
|
||||||
|
const SITE_ASSET_RE = /\.(m?js|jsx|tsx?|vue|svelte|css|s[ac]ss|less)$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An HTML file that is a web app's own page, not a report (`app/www/index.html`
|
||||||
|
* of a project not yet committed): an `index.html`, one under a site folder
|
||||||
|
* (`SITE_DIR_RE`), or one beside scripts or stylesheets in its folder. The
|
||||||
|
* workspace root is exempt from that last test — a repo keeps its config
|
||||||
|
* `.js` there next to the reports people write.
|
||||||
|
*/
|
||||||
|
async function isSitePage(cwd, rel) {
|
||||||
|
const parts = rel.split("/");
|
||||||
|
const name = parts.pop();
|
||||||
|
if (/^index\.html?$/i.test(name)) return true;
|
||||||
|
if (parts.some((d) => SITE_DIR_RE.test(d))) return true;
|
||||||
|
if (!parts.length) return false;
|
||||||
|
try {
|
||||||
|
const names = await fs.promises.readdir(path.join(cwd, ...parts));
|
||||||
|
return names.some((n) => SITE_ASSET_RE.test(n));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
const capturing = new Map();
|
const capturing = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The workspace's documents a session created or changed since it started:
|
* The workspace's documents a session created or changed since it started:
|
||||||
* untracked or modified files in a repo (an HTML file only when untracked, so a
|
* untracked or modified files in a repo (an HTML file only when untracked and
|
||||||
* site's own pages are not taken), every file of a plain folder. Paths are
|
* not a site's own page — `isSitePage`), every file of a plain folder. Paths
|
||||||
* workspace-relative.
|
* are workspace-relative.
|
||||||
*/
|
*/
|
||||||
async function outputCandidates(lane, since) {
|
async function outputCandidates(lane, since) {
|
||||||
const gd = require("./git-diff");
|
const gd = require("./git-diff");
|
||||||
@@ -572,7 +599,7 @@ async function outputCandidates(lane, since) {
|
|||||||
const out = [];
|
const out = [];
|
||||||
for (const rel of new Set(paths)) {
|
for (const rel of new Set(paths)) {
|
||||||
if (!OUTPUT_EXT.test(rel)) continue;
|
if (!OUTPUT_EXT.test(rel)) continue;
|
||||||
if (/\.html?$/i.test(rel) && tracked.has(rel)) continue;
|
if (/\.html?$/i.test(rel) && (tracked.has(rel) || (await isSitePage(lane.cwd, rel)))) continue;
|
||||||
let st;
|
let st;
|
||||||
try {
|
try {
|
||||||
st = await fs.promises.lstat(path.join(lane.cwd, rel));
|
st = await fs.promises.lstat(path.join(lane.cwd, rel));
|
||||||
@@ -627,7 +654,7 @@ async function captureOutputsNow(laneId, sessionId) {
|
|||||||
const files = JSON.stringify([{ path: shot.name, size: shot.size, mtime: mtimeMs }]);
|
const files = JSON.stringify([{ path: shot.name, size: shot.size, mtime: mtimeMs }]);
|
||||||
if (row) {
|
if (row) {
|
||||||
sql(
|
sql(
|
||||||
"UPDATE artifacts SET snapshot = ?, files = ?, size = ?, session_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
"UPDATE artifacts SET snapshot = ?, files = ?, size = ?, session_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||||
).run(shot.snap.id, files, shot.size, String(sessionId), row.id);
|
).run(shot.snap.id, files, shot.size, String(sessionId), row.id);
|
||||||
dropSnapshot(row.snapshot);
|
dropSnapshot(row.snapshot);
|
||||||
done.push(sql("SELECT * FROM artifacts WHERE id = ?").get(row.id));
|
done.push(sql("SELECT * FROM artifacts WHERE id = ?").get(row.id));
|
||||||
@@ -1125,8 +1152,20 @@ function saveSkillFiles(req, { name, files, title, description }) {
|
|||||||
* `paths`). Answers every skill it made. Nothing is stored unless every
|
* `paths`). Answers every skill it made. Nothing is stored unless every
|
||||||
* skill in the upload passes.
|
* skill in the upload passes.
|
||||||
*/
|
*/
|
||||||
function uploadSkills(req, { uploads, name, title, description }) {
|
function uploadSkills(req, { uploads, name, title, description, id }) {
|
||||||
|
// `id`: Share again for a skill the server has no copy of — the upload
|
||||||
|
// replaces THAT row (one skill, named as the row is).
|
||||||
|
let onto = null;
|
||||||
|
if (id != null && id !== "") {
|
||||||
|
onto = load(req, id);
|
||||||
|
if (!canChange(req, onto)) throw fail("EFORBIDDEN", "only its owner changes an artifact");
|
||||||
|
if (onto.kind !== "skill") throw fail("EBADKIND", "only a skill is shared again");
|
||||||
|
name = onto.source;
|
||||||
|
}
|
||||||
const found = skillsFromUpload(uploads || [], { name, description });
|
const found = skillsFromUpload(uploads || [], { name, description });
|
||||||
|
if (onto && found.length !== 1) {
|
||||||
|
throw fail("EBADUPLOAD", "choose the folder or .zip of this one skill");
|
||||||
|
}
|
||||||
const typed = typeof name === "string" && name.trim();
|
const typed = typeof name === "string" && name.trim();
|
||||||
const prepared = found.map((sk) => ({
|
const prepared = found.map((sk) => ({
|
||||||
name: validSkillName(found.length === 1 && typed ? typed : sk.fallbackName || ""),
|
name: validSkillName(found.length === 1 && typed ? typed : sk.fallbackName || ""),
|
||||||
@@ -1140,16 +1179,46 @@ function uploadSkills(req, { uploads, name, title, description }) {
|
|||||||
withFrontmatter(md.data.toString("utf8"), p.name, found.length === 1 ? description : "");
|
withFrontmatter(md.data.toString("utf8"), p.name, found.length === 1 ? description : "");
|
||||||
checkSkillFiles(p.files);
|
checkSkillFiles(p.files);
|
||||||
}
|
}
|
||||||
return prepared.map((p) =>
|
return prepared.map((p) => {
|
||||||
saveSkillFiles(req, {
|
// A skill its uploader already has (same name, not a workspace's) gets
|
||||||
|
// the new version on that row — installers see Update — never a twin.
|
||||||
|
const own = onto || ownSkillRow(req, p.name);
|
||||||
|
if (own) return replaceUploaded(req, own, p, found.length === 1 ? description : "");
|
||||||
|
return saveSkillFiles(req, {
|
||||||
name: p.name,
|
name: p.name,
|
||||||
files: p.files,
|
files: p.files,
|
||||||
title: found.length === 1 ? title : "",
|
title: found.length === 1 ? title : "",
|
||||||
description: found.length === 1 ? description : "",
|
description: found.length === 1 ? description : "",
|
||||||
})
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The caller's newest skill row of that name taken from no workspace, or null. */
|
||||||
|
function ownSkillRow(req, name) {
|
||||||
|
const owner = req?.user ? req.user.id : null;
|
||||||
|
return (
|
||||||
|
sql(
|
||||||
|
`SELECT * FROM artifacts WHERE kind = 'skill' AND source = ? AND lane_id IS NULL
|
||||||
|
AND snapshot IS NOT NULL AND owner_id IS ? ORDER BY updated_at DESC, id DESC LIMIT 1`
|
||||||
|
).get(name, owner) || null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An uploaded skill onto an existing row: same frontmatter rules as a new one. */
|
||||||
|
function replaceUploaded(req, row, { name, files }, description) {
|
||||||
|
const md = files.find((f) => f.path === "SKILL.md");
|
||||||
|
const text = md.data.toString("utf8");
|
||||||
|
if (text.includes("\uFFFD")) throw fail("EBADSKILL", "SKILL.md is not a UTF-8 text file");
|
||||||
|
const fixed = Buffer.from(withFrontmatter(text, name, description), "utf8");
|
||||||
|
const all = [{ path: "SKILL.md", data: fixed }, ...files.filter((f) => f !== md)];
|
||||||
|
checkSkillFiles(all);
|
||||||
|
const changed = contentHash(all) !== snapshotVersion(row);
|
||||||
|
// The owner's folder as it is now (like `storeSkill`): only a LATER change
|
||||||
|
// to it replaces this upload.
|
||||||
|
if (changed) replaceSnapshot(row, all, sourceHash(row) ?? row.source_hash);
|
||||||
|
return { ...shareNow(req, row.id, changed), replaced: true, changed };
|
||||||
|
}
|
||||||
|
|
||||||
/** A skill written in the dialog: a name, a description and its instructions. */
|
/** A skill written in the dialog: a name, a description and its instructions. */
|
||||||
function writeSkill(req, { name, description, content, title }) {
|
function writeSkill(req, { name, description, content, title }) {
|
||||||
const n = validSkillName(name);
|
const n = validSkillName(name);
|
||||||
@@ -1599,7 +1668,8 @@ function editSkill(req, row, { name, description, content }) {
|
|||||||
sql(
|
sql(
|
||||||
`UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?,
|
`UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?,
|
||||||
version = ?, source_hash = ?,
|
version = ?, source_hash = ?,
|
||||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
|
||||||
|
version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||||
).run(
|
).run(
|
||||||
n,
|
n,
|
||||||
snap.id,
|
snap.id,
|
||||||
@@ -1936,6 +2006,23 @@ function syncOne(row) {
|
|||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
replaceSnapshot(row, files, st.hash);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point a skill row at a NEW snapshot holding `files` (`[{path, data}]`, already
|
||||||
|
* checked), then drop the old one — never a write into the snapshot being
|
||||||
|
* read. Installers then see Update (`updateCheck`). `sourceHash` is the
|
||||||
|
* source folder's state to record, so `syncOne` takes only a LATER change.
|
||||||
|
* The description follows SKILL.md's only while it still says what the old
|
||||||
|
* one did.
|
||||||
|
*/
|
||||||
|
function replaceSnapshot(row, files, sourceHash) {
|
||||||
const snap = newSnapshotDir();
|
const snap = newSnapshotDir();
|
||||||
let total = 0;
|
let total = 0;
|
||||||
const next = [];
|
const next = [];
|
||||||
@@ -1947,11 +2034,10 @@ function syncOne(row) {
|
|||||||
total += f.data.length;
|
total += f.data.length;
|
||||||
next.push({ path: f.path, size: f.data.length });
|
next.push({ path: f.path, size: f.data.length });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
dropSnapshot(snap.id);
|
dropSnapshot(snap.id);
|
||||||
return false;
|
throw err;
|
||||||
}
|
}
|
||||||
// The description follows SKILL.md's only while it still says what the old one did.
|
|
||||||
const md = (list) => list.find((f) => f.path === "SKILL.md");
|
const md = (list) => list.find((f) => f.path === "SKILL.md");
|
||||||
let oldSummary = "";
|
let oldSummary = "";
|
||||||
try {
|
try {
|
||||||
@@ -1969,10 +2055,98 @@ function syncOne(row) {
|
|||||||
: row.description;
|
: row.description;
|
||||||
sql(
|
sql(
|
||||||
`UPDATE artifacts SET snapshot = ?, files = ?, size = ?, version = ?, source_hash = ?,
|
`UPDATE artifacts SET snapshot = ?, files = ?, size = ?, version = ?, source_hash = ?,
|
||||||
description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
|
||||||
).run(snap.id, JSON.stringify(next), total, st.hash, st.hash, description, row.id);
|
version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||||
|
).run(
|
||||||
|
snap.id,
|
||||||
|
JSON.stringify(next),
|
||||||
|
total,
|
||||||
|
contentHash(files),
|
||||||
|
sourceHash ?? null,
|
||||||
|
description,
|
||||||
|
row.id
|
||||||
|
);
|
||||||
if (row.snapshot && SNAPSHOT_RE.test(row.snapshot)) dropSnapshot(row.snapshot);
|
if (row.snapshot && SNAPSHOT_RE.test(row.snapshot)) dropSnapshot(row.snapshot);
|
||||||
return true;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The owner's skill folder on THIS server as it is now, for Share again:
|
||||||
|
* the row's own source folder (`sourceDirOf`), else — a skill not taken from
|
||||||
|
* a workspace — the same-named skill of a plugin the owner installed at user
|
||||||
|
* scope (`installed_plugins.json`; the most recently updated when several
|
||||||
|
* have it), never outside the owner's `~/.claude/plugins/`. Answers
|
||||||
|
* `{st, fromSource}` (`folderState`) or null when the server has no copy (an
|
||||||
|
* uploaded skill: its owner picks the folder again).
|
||||||
|
*/
|
||||||
|
function machineSkill(row) {
|
||||||
|
const src = sourceDirOf(row);
|
||||||
|
const st = src && folderState(src.dir, src.top);
|
||||||
|
if (st) return { st, fromSource: true };
|
||||||
|
if (!src || row.lane_id != null) return null;
|
||||||
|
const top = path.join(src.top, "plugins");
|
||||||
|
let installed = {};
|
||||||
|
try {
|
||||||
|
const user = sql("SELECT id, username, role FROM users WHERE id = ?").get(row.owner_id);
|
||||||
|
const file = path.join(top, "installed_plugins.json");
|
||||||
|
const text =
|
||||||
|
user && ownAccount({ user }).acct
|
||||||
|
? require("./run-as").readFileSafe(file, "utf8", { max: 4 << 20 })
|
||||||
|
: fs.readFileSync(file, "utf8");
|
||||||
|
installed = JSON.parse(text)?.plugins || {};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let best = null;
|
||||||
|
for (const entries of Object.values(installed)) {
|
||||||
|
for (const e of Array.isArray(entries) ? entries : []) {
|
||||||
|
if (!e || e.scope !== "user" || typeof e.installPath !== "string") continue;
|
||||||
|
const st2 = folderState(path.join(e.installPath, "skills", row.source), top);
|
||||||
|
const at = Date.parse(e.lastUpdated || e.installedAt || "") || 0;
|
||||||
|
if (st2 && (!best || at > best.at)) best = { st: st2, at };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best ? { st: best.st, fromSource: false } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share again: take the owner's skill folder on this server as the row's new
|
||||||
|
* snapshot (`machineSkill`) and share it (server mode), so installers are
|
||||||
|
* offered Update. ENOSOURCE when the server has no copy — the client then
|
||||||
|
* asks for the folder or .zip and uploads it onto this row (`uploadSkills`
|
||||||
|
* with `id`). Answers `{item, changed}`.
|
||||||
|
*/
|
||||||
|
function reshareSkill(req, id) {
|
||||||
|
const row = load(req, id);
|
||||||
|
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner changes an artifact");
|
||||||
|
if (row.kind !== "skill") throw fail("EBADKIND", "only a skill is shared again");
|
||||||
|
const found = machineSkill(row);
|
||||||
|
if (!found) {
|
||||||
|
throw fail("ENOSOURCE", "this server has no copy of the skill: choose its folder or .zip");
|
||||||
|
}
|
||||||
|
const files = found.st.read();
|
||||||
|
checkSkillFiles(files);
|
||||||
|
const changed = found.st.hash !== snapshotVersion(row);
|
||||||
|
if (changed) {
|
||||||
|
replaceSnapshot(row, files, found.fromSource ? found.st.hash : row.source_hash);
|
||||||
|
} else if (found.fromSource) {
|
||||||
|
sql("UPDATE artifacts SET source_hash = ? WHERE id = ?").run(found.st.hash, row.id);
|
||||||
|
}
|
||||||
|
return { item: shareNow(req, row.id, changed), changed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Share a row (server mode, when it is not yet) and answer its view; `audit` names a new version. */
|
||||||
|
function shareNow(req, id, changed) {
|
||||||
|
const row = sql("SELECT * FROM artifacts WHERE id = ?").get(id);
|
||||||
|
if (isServer() && row.shared !== 1) {
|
||||||
|
sql(
|
||||||
|
`UPDATE artifacts SET shared = 1, shared_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
|
||||||
|
).run(id);
|
||||||
|
audit(req, row, "shared");
|
||||||
|
} else if (changed && row.shared === 1) {
|
||||||
|
audit(req, row, "updated");
|
||||||
|
}
|
||||||
|
return view(sql("SELECT * FROM artifacts WHERE id = ?").get(id), req);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SYNC_EVERY_MS = 15 * 1000;
|
const SYNC_EVERY_MS = 15 * 1000;
|
||||||
@@ -2163,6 +2337,36 @@ async function uninstall(req, id) {
|
|||||||
* the installer and an admin. `unrecorded` = installs counted before this
|
* the installer and an admin. `unrecorded` = installs counted before this
|
||||||
* list was kept.
|
* list was kept.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Where one recorded install of a skill stands now, from its Claude
|
||||||
|
* `skills/<name>` copies (one per account it went into; the cross-tool
|
||||||
|
* `~/.agents/skills` twins are the same files) —
|
||||||
|
* `outdated` when any differs from the row's newest snapshot (its user is
|
||||||
|
* offered Update), `removed` when none is there any more, `unknown` when one
|
||||||
|
* is there but unreadable as a skill, else `latest`. Only hashes are
|
||||||
|
* compared; no content leaves the server. The paths are the ones the server
|
||||||
|
* itself recorded at install, read like `updateCheck` reads the viewer's own.
|
||||||
|
*/
|
||||||
|
function installState(row, paths) {
|
||||||
|
const version = snapshotVersion(row);
|
||||||
|
const dirs = (Array.isArray(paths) ? paths : []).filter(
|
||||||
|
(p) =>
|
||||||
|
typeof p === "string" &&
|
||||||
|
path.basename(p) === row.source &&
|
||||||
|
path.basename(path.dirname(p)) === "skills" &&
|
||||||
|
path.basename(path.dirname(path.dirname(p))) !== ".agents"
|
||||||
|
);
|
||||||
|
if (!version || !dirs.length) return "unknown";
|
||||||
|
const states = dirs.map((dir) => {
|
||||||
|
const st = folderState(dir, path.dirname(path.dirname(dir)));
|
||||||
|
if (st) return st.hash === version ? "latest" : "outdated";
|
||||||
|
return exists(dir) ? "unknown" : "removed";
|
||||||
|
});
|
||||||
|
if (states.includes("outdated")) return "outdated";
|
||||||
|
if (states.every((x) => x === "removed")) return "removed";
|
||||||
|
return states.includes("unknown") ? "unknown" : "latest";
|
||||||
|
}
|
||||||
|
|
||||||
function installsOf(req, id) {
|
function installsOf(req, id) {
|
||||||
const row = load(req, id);
|
const row = load(req, id);
|
||||||
const u = req?.user;
|
const u = req?.user;
|
||||||
@@ -2186,6 +2390,7 @@ function installsOf(req, id) {
|
|||||||
mine,
|
mine,
|
||||||
at: r.installed_at,
|
at: r.installed_at,
|
||||||
replaced: r.replaced === 1,
|
replaced: r.replaced === 1,
|
||||||
|
...(row.kind === "skill" ? { state: installState(row, paths) } : {}),
|
||||||
...(mine || u?.role === "admin" ? { paths } : {}),
|
...(mine || u?.role === "admin" ? { paths } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -2215,6 +2420,7 @@ module.exports = {
|
|||||||
availableSkills,
|
availableSkills,
|
||||||
addSkill,
|
addSkill,
|
||||||
uploadSkills,
|
uploadSkills,
|
||||||
|
reshareSkill,
|
||||||
writeSkill,
|
writeSkill,
|
||||||
uploadFile,
|
uploadFile,
|
||||||
skillSlug,
|
skillSlug,
|
||||||
|
|||||||
+52
-2
@@ -20,7 +20,8 @@
|
|||||||
* admin on the Users page (`server/routes/users.js`), where every change
|
* admin on the Users page (`server/routes/users.js`), where every change
|
||||||
* needs the admin's own password again (`confirmPassword`, throttled).
|
* needs the admin's own password again (`confirmPassword`, throttled).
|
||||||
* An account also carries an optional display name and email
|
* An account also carries an optional display name and email
|
||||||
* (`validateProfile`), which its owner edits in Settings → Account without
|
* (`validateProfile`; no two accounts may read as one name — `assertNameFree`,
|
||||||
|
* ENAMETAKEN), which its owner edits in Settings → Account without
|
||||||
* that step-up; changing their OWN password there needs the old one and
|
* that step-up; changing their OWN password there needs the old one and
|
||||||
* keeps the login it was changed from (`setPassword`'s `keepToken`).
|
* 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
|
* - Two password bars: an admin may GIVE any non-empty password (a create or a
|
||||||
@@ -212,6 +213,9 @@ const publicUser = (u) =>
|
|||||||
createdAt: u.created_at ?? null,
|
createdAt: u.created_at ?? null,
|
||||||
// An admin gave this password: the account must pick its own first.
|
// An admin gave this password: the account must pick its own first.
|
||||||
mustChangePassword: Boolean(u.must_change_password),
|
mustChangePassword: Boolean(u.must_change_password),
|
||||||
|
// Owns the host's Linux account: its sessions outside every workspace
|
||||||
|
// are billed to this admin (server/lib/session-account.js).
|
||||||
|
hostAccount: Boolean(u.host_account),
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_DISPLAY_NAME = 100;
|
const MAX_DISPLAY_NAME = 100;
|
||||||
@@ -247,6 +251,47 @@ function validateProfile(body) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Latin look-alikes from other scripts, so "Vĩ" in Cyrillic still collides.
|
||||||
|
const CONFUSABLE = {
|
||||||
|
а: "a", в: "b", е: "e", к: "k", м: "m", н: "h", о: "o", р: "p", с: "c", т: "t",
|
||||||
|
у: "y", х: "x", і: "i", ј: "j", ԁ: "d", ѕ: "s", ӏ: "l", α: "a", β: "b", ε: "e",
|
||||||
|
η: "n", ι: "i", κ: "k", ν: "v", ο: "o", ρ: "p", τ: "t", υ: "u", χ: "x", "ı": "i",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name two accounts may not share: no marks (Vĩ = Vi), đ = d, any case,
|
||||||
|
* look-alike letters folded, and only letters and digits outside brackets —
|
||||||
|
* so "Nguyễn Ngọc Trí Vĩ (you)" and "nguyen.ngoc-tri vi" are both one name.
|
||||||
|
*/
|
||||||
|
function nameKey(s) {
|
||||||
|
return String(s || "")
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/\p{M}/gu, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\([^)]*\)|\[[^\]]*\]/g, "")
|
||||||
|
.replace(/[đð]/g, "d")
|
||||||
|
.replace(/./gu, (c) => CONFUSABLE[c] ?? c)
|
||||||
|
.replace(/[^\p{L}\p{N}]/gu, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuse (ENAMETAKEN) a display name that reads as another account's display
|
||||||
|
* name or username, so no one can pose as a colleague. Keeping the name one
|
||||||
|
* already has is always allowed — a clash from before this rule is no reason
|
||||||
|
* to block an admin's unrelated edit.
|
||||||
|
*/
|
||||||
|
function assertNameFree(userId, fields) {
|
||||||
|
if (!("display_name" in fields) || !fields.display_name) return;
|
||||||
|
const key = nameKey(fields.display_name);
|
||||||
|
if (!key) return;
|
||||||
|
const own = sql("SELECT display_name FROM users WHERE id = ?").get(userId);
|
||||||
|
if (own && own.display_name === fields.display_name) return;
|
||||||
|
const rows = sql("SELECT id, username, display_name FROM users WHERE id != ?").all(userId);
|
||||||
|
if (rows.some((u) => nameKey(u.username) === key || nameKey(u.display_name) === key)) {
|
||||||
|
throw err("ENAMETAKEN", "displayName: another account already uses that name");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Write `validateProfile`'s result for one account. */
|
/** Write `validateProfile`'s result for one account. */
|
||||||
function setProfile(userId, fields) {
|
function setProfile(userId, fields) {
|
||||||
for (const [col, value] of Object.entries(fields)) {
|
for (const [col, value] of Object.entries(fields)) {
|
||||||
@@ -373,7 +418,10 @@ function setRole(username, role) {
|
|||||||
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
|
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
|
||||||
const u = requireUser(username);
|
const u = requireUser(username);
|
||||||
if (role !== "admin") assertKeepsAdmin(u, "demoted");
|
if (role !== "admin") assertKeepsAdmin(u, "demoted");
|
||||||
sql("UPDATE users SET role = ? WHERE id = ?").run(role, u.id);
|
// Only an admin owns the host account (session-account.js): a demotion drops the mark.
|
||||||
|
sql(
|
||||||
|
"UPDATE users SET role = ?, host_account = CASE WHEN ? = 'admin' THEN host_account ELSE 0 END WHERE id = ?"
|
||||||
|
).run(role, role, u.id);
|
||||||
if (role !== "admin") stopHostPanes(u.id);
|
if (role !== "admin") stopHostPanes(u.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,6 +860,8 @@ module.exports = {
|
|||||||
unassignWorkspace,
|
unassignWorkspace,
|
||||||
ownedWorkspaces,
|
ownedWorkspaces,
|
||||||
validateProfile,
|
validateProfile,
|
||||||
|
nameKey,
|
||||||
|
assertNameFree,
|
||||||
setProfile,
|
setProfile,
|
||||||
validatePassword,
|
validatePassword,
|
||||||
validateOwnPassword,
|
validateOwnPassword,
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
* home AgentHub placed that account in, never dated in the future, and
|
* home AgentHub placed that account in, never dated in the future, and
|
||||||
* only when its id_token names the same account (`mayCopyBack`); the 2026-10-08
|
* only when its id_token names the same account (`mayCopyBack`); the 2026-10-08
|
||||||
* spike showed an old refresh token still refreshes, so panes sharing an
|
* spike showed an old refresh token still refreshes, so panes sharing an
|
||||||
* account do not log each other out.
|
* account do not log each other out. Such a pane runs `--no-daemon`
|
||||||
|
* (when its Codex has the flag): the home's shared app-server daemon
|
||||||
|
* would keep serving the login it started with.
|
||||||
*
|
*
|
||||||
* Records live in `<CLAUDE_HOME>/agenthub-codex-accounts/<name>.json` (dir
|
* Records live in `<CLAUDE_HOME>/agenthub-codex-accounts/<name>.json` (dir
|
||||||
* 0700, file 0600); no route returns a key or a token.
|
* 0700, file 0600); no route returns a key or a token.
|
||||||
@@ -424,6 +426,46 @@ function liveInHome(name, member) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Codex ≥ 0.164 hands a plain TUI (no `-c` override) to the CODEX_HOME's shared
|
||||||
|
// app-server daemon, which outlives every TUI and keeps the login it started
|
||||||
|
// with — so a ChatGPT login placed in the home reached no pane while the
|
||||||
|
// member's daemon ran on the previous one (reproduced on 0.164.0). A pool pane
|
||||||
|
// runs its own app server instead. An older Codex has neither the daemon nor
|
||||||
|
// the flag (it would refuse to start), so the flag goes only to a binary whose
|
||||||
|
// --help lists it: probed once per binary (path, size, mtime).
|
||||||
|
const noDaemonCache = new Map();
|
||||||
|
|
||||||
|
function noDaemonArgv(member) {
|
||||||
|
const bin =
|
||||||
|
process.env.AGENTHUB_CODEX_BIN ||
|
||||||
|
(member
|
||||||
|
? require("./shared-tools").sharedPath("codex")
|
||||||
|
: require("./ai-tools").detect("codex").path);
|
||||||
|
if (!bin) return [];
|
||||||
|
let key;
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(bin);
|
||||||
|
key = `${fs.realpathSync(bin)}:${st.size}:${st.mtimeMs}`;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!noDaemonCache.has(key)) {
|
||||||
|
let help = "";
|
||||||
|
try {
|
||||||
|
help = require("node:child_process").execFileSync(bin, ["--help"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 5000,
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* no answer: leave the flag out, as before */
|
||||||
|
}
|
||||||
|
noDaemonCache.set(key, /--no-daemon\b/.test(help));
|
||||||
|
}
|
||||||
|
return noDaemonCache.get(key) ? ["--no-daemon"] : [];
|
||||||
|
}
|
||||||
|
|
||||||
/** `{name, kind}` of the account a new Codex pane would get, or null. */
|
/** `{name, kind}` of the account a new Codex pane would get, or null. */
|
||||||
function pickAccount(member = null, ownerId = null) {
|
function pickAccount(member = null, ownerId = null) {
|
||||||
const a = pick(member, ownerId);
|
const a = pick(member, ownerId);
|
||||||
@@ -449,7 +491,7 @@ function forSpawn(member = null, ownerId = null) {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
name: a.name,
|
name: a.name,
|
||||||
argv: [],
|
argv: noDaemonArgv(member),
|
||||||
env: {},
|
env: {},
|
||||||
place: () => {
|
place: () => {
|
||||||
syncHome(member.home); // a live pane may hold a newer login than ours
|
syncHome(member.home); // a live pane may hold a newer login than ours
|
||||||
|
|||||||
+60
-8
@@ -61,6 +61,19 @@ async function readCapped(dir, args, capChars) {
|
|||||||
const MAX_FILES = 300;
|
const MAX_FILES = 300;
|
||||||
/** Most patch bytes read for ONE file before its hunks are cut short. */
|
/** Most patch bytes read for ONE file before its hunks are cut short. */
|
||||||
const MAX_FILE_BYTES = 200_000;
|
const MAX_FILE_BYTES = 200_000;
|
||||||
|
/** Characters of one file's raw patch read before its long lines are cut down
|
||||||
|
* (`elideLongLines`) and `MAX_FILE_BYTES` applied. A page that embeds a video
|
||||||
|
* or a font as base64 is a few lines of megabytes each: read at
|
||||||
|
* `MAX_FILE_BYTES`, its first such line used the whole budget and every hunk
|
||||||
|
* after it was lost. Past this the patch is still truncated. */
|
||||||
|
const MAX_PATCH_READ_CHARS = 16 * 1024 * 1024;
|
||||||
|
/** A diff row longer than this is shown cut down to `LONG_LINE_KEEP`
|
||||||
|
* characters, with how many it hides (`elided` on the parsed line). */
|
||||||
|
const LONG_LINE_CHARS = 4000;
|
||||||
|
const LONG_LINE_KEEP = 1000;
|
||||||
|
/** Ends a cut-down row in the raw patch: a private-use character, then the
|
||||||
|
* count of characters hidden. `parseUnifiedDiff` turns it into `elided`. */
|
||||||
|
const ELIDED_MARK = "\uE000";
|
||||||
/** Most patch bytes across all files in a `full` (review-mode) response. */
|
/** Most patch bytes across all files in a `full` (review-mode) response. */
|
||||||
const MAX_TOTAL_BYTES = 1_000_000;
|
const MAX_TOTAL_BYTES = 1_000_000;
|
||||||
/** An untracked file larger than this is reported without content. */
|
/** An untracked file larger than this is reported without content. */
|
||||||
@@ -259,15 +272,23 @@ function parseUnifiedDiff(patch) {
|
|||||||
// row of its own and carries no line number.
|
// row of its own and carries no line number.
|
||||||
if (raw.startsWith("\\")) continue;
|
if (raw.startsWith("\\")) continue;
|
||||||
const marker = raw[0];
|
const marker = raw[0];
|
||||||
const text = raw.slice(1);
|
let text = raw.slice(1);
|
||||||
|
// A row `elideLongLines` cut down: the text it kept, and how much it hid.
|
||||||
|
let elided = 0;
|
||||||
|
const cut = text.lastIndexOf(ELIDED_MARK);
|
||||||
|
if (cut >= 0 && /^\d+$/.test(text.slice(cut + 1))) {
|
||||||
|
elided = Number(text.slice(cut + 1));
|
||||||
|
text = text.slice(0, cut);
|
||||||
|
}
|
||||||
|
const extra = elided ? { elided } : {};
|
||||||
if (marker === "+") {
|
if (marker === "+") {
|
||||||
current.lines.push({ type: "add", oldLine: null, newLine: newLine++, text });
|
current.lines.push({ type: "add", oldLine: null, newLine: newLine++, text, ...extra });
|
||||||
} else if (marker === "-") {
|
} else if (marker === "-") {
|
||||||
current.lines.push({ type: "del", oldLine: oldLine++, newLine: null, text });
|
current.lines.push({ type: "del", oldLine: oldLine++, newLine: null, text, ...extra });
|
||||||
} else if (marker === " ") {
|
} else if (marker === " ") {
|
||||||
// An unchanged EMPTY line is " " — one space, no text. A truly empty
|
// An unchanged EMPTY line is " " — one space, no text. A truly empty
|
||||||
// string is the tail of the final "\n", not a row.
|
// string is the tail of the final "\n", not a row.
|
||||||
current.lines.push({ type: "ctx", oldLine: oldLine++, newLine: newLine++, text });
|
current.lines.push({ type: "ctx", oldLine: oldLine++, newLine: newLine++, text, ...extra });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return hunks;
|
return hunks;
|
||||||
@@ -532,7 +553,27 @@ async function patchFor(dir, { range, baseRef, sha, file, untracked, context })
|
|||||||
: range === "branch"
|
: range === "branch"
|
||||||
? ["diff", u, `${baseRef}...HEAD`, "--", file]
|
? ["diff", u, `${baseRef}...HEAD`, "--", file]
|
||||||
: ["diff", u, "HEAD", "--", file];
|
: ["diff", u, "HEAD", "--", file];
|
||||||
return readCapped(dir, [...args, "--no-color", "--no-ext-diff"], MAX_FILE_BYTES).catch(() => "");
|
return readCapped(dir, [...args, "--no-color", "--no-ext-diff"], MAX_PATCH_READ_CHARS).catch(
|
||||||
|
() => ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The patch with every row longer than `LONG_LINE_CHARS` cut down to its first
|
||||||
|
* `LONG_LINE_KEEP` characters plus `ELIDED_MARK` and the count it hides. Only
|
||||||
|
* what the panel SHOWS is cut: the hunk actions (`hunkPatch`, `selectionPatch`)
|
||||||
|
* re-read git's own diff and match hunks by header, which this never changes.
|
||||||
|
*/
|
||||||
|
function elideLongLines(patch) {
|
||||||
|
if (patch.length <= LONG_LINE_CHARS) return patch;
|
||||||
|
return patch
|
||||||
|
.split("\n")
|
||||||
|
.map((row) =>
|
||||||
|
row.length > LONG_LINE_CHARS && !row.startsWith("@@")
|
||||||
|
? `${row.slice(0, LONG_LINE_KEEP + 1)}${ELIDED_MARK}${row.length - LONG_LINE_KEEP - 1}`
|
||||||
|
: row
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -648,7 +689,15 @@ async function collectChanges(
|
|||||||
untracked: entry.untracked,
|
untracked: entry.untracked,
|
||||||
context: ctx,
|
context: ctx,
|
||||||
});
|
});
|
||||||
|
// A read that hit its own ceiling came back partial: still cut short,
|
||||||
|
// however small eliding its long rows makes it.
|
||||||
|
const partial = patch.length >= MAX_PATCH_READ_CHARS;
|
||||||
|
patch = elideLongLines(patch);
|
||||||
const cap = Math.min(MAX_FILE_BYTES, budget);
|
const cap = Math.min(MAX_FILE_BYTES, budget);
|
||||||
|
if (partial) {
|
||||||
|
entry.truncated = true;
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
if (patch.length > cap) {
|
if (patch.length > cap) {
|
||||||
// Cut on a line boundary so the last row is a whole line — but only
|
// Cut on a line boundary so the last row is a whole line — but only
|
||||||
// when that boundary is near the cap. A minified file is one enormous
|
// when that boundary is near the cap. A minified file is one enormous
|
||||||
@@ -1192,16 +1241,18 @@ async function diffHunks(dir, file, context) {
|
|||||||
* in file order, to be applied FORWARD onto an index holding HEAD. Every
|
* in file order, to be applied FORWARD onto an index holding HEAD. Every
|
||||||
* header is matched against a fresh diff like `hunkPatch`'s; null when one is
|
* header is matched against a fresh diff like `hunkPatch`'s; null when one is
|
||||||
* gone or names a block the hunk lacks. Each hunk's new-side start is left as
|
* gone or names a block the hunk lacks. Each hunk's new-side start is left as
|
||||||
* git wrote it — `git apply` locates a hunk by its old side.
|
* git wrote it — `git apply` locates a hunk by its old side. `reverse` cuts
|
||||||
|
* it for `apply -R` off the working tree instead (the partial discard, #87),
|
||||||
|
* where the NEW side is the one checked and every start stays git's own.
|
||||||
*/
|
*/
|
||||||
async function selectionPatch(dir, file, context, picks) {
|
async function selectionPatch(dir, file, context, picks, reverse = false) {
|
||||||
const diff = await diffHunks(dir, file, context);
|
const diff = await diffHunks(dir, file, context);
|
||||||
if (!diff || !picks.length) return null;
|
if (!diff || !picks.length) return null;
|
||||||
const at = (header) => diff.hunks.findIndex((h) => h.slice(0, h.indexOf("\n")) === header);
|
const at = (header) => diff.hunks.findIndex((h) => h.slice(0, h.indexOf("\n")) === header);
|
||||||
if (picks.some((p) => at(p.hunk) < 0)) return null;
|
if (picks.some((p) => at(p.hunk) < 0)) return null;
|
||||||
const bodies = [];
|
const bodies = [];
|
||||||
for (const p of [...picks].sort((x, y) => at(x.hunk) - at(y.hunk))) {
|
for (const p of [...picks].sort((x, y) => at(x.hunk) - at(y.hunk))) {
|
||||||
const body = onlyBlock(diff.hunks[at(p.hunk)], p.blocks, false);
|
const body = onlyBlock(diff.hunks[at(p.hunk)], p.blocks, reverse);
|
||||||
if (!body) return null;
|
if (!body) return null;
|
||||||
bodies.push(body);
|
bodies.push(body);
|
||||||
}
|
}
|
||||||
@@ -1334,6 +1385,7 @@ async function plainTreeAll(dir) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
elideLongLines,
|
||||||
collectChanges,
|
collectChanges,
|
||||||
CONFLICT_CODES,
|
CONFLICT_CODES,
|
||||||
contentSha,
|
contentSha,
|
||||||
|
|||||||
+56
-14
@@ -424,6 +424,13 @@ async function runGitAction(dir, action, body = {}) {
|
|||||||
|
|
||||||
if (action === "discard") {
|
if (action === "discard") {
|
||||||
if (body.confirm !== true) throw bad("ECONFIRM", "discard requires confirm: true");
|
if (body.confirm !== true) throw bad("ECONFIRM", "discard requires confirm: true");
|
||||||
|
// Ticked change blocks of partly ticked files (#87) go first: each patch
|
||||||
|
// is built before any is applied, so a stale one refuses before the
|
||||||
|
// irreversible whole-file part below has run.
|
||||||
|
const partial = hasHunks(body) ? await discardSelection(dir, body) : null;
|
||||||
|
if (partial && !(Array.isArray(body.paths) && body.paths.length)) {
|
||||||
|
return done(action, partial);
|
||||||
|
}
|
||||||
const paths = await resolvePaths(dir, body);
|
const paths = await resolvePaths(dir, body);
|
||||||
const untracked = paths.filter((p) => p.code === "??").map((p) => p.path);
|
const untracked = paths.filter((p) => p.code === "??").map((p) => p.path);
|
||||||
// A rename's ORIGINAL path rides along: restoring only the new one would
|
// A rename's ORIGINAL path rides along: restoring only the new one would
|
||||||
@@ -431,7 +438,7 @@ async function runGitAction(dir, action, body = {}) {
|
|||||||
const tracked = paths
|
const tracked = paths
|
||||||
.filter((p) => p.code !== "??")
|
.filter((p) => p.code !== "??")
|
||||||
.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]));
|
.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]));
|
||||||
const out = [];
|
const out = partial ? [partial] : [];
|
||||||
// Untracked files are deleted (git has no copy to restore); tracked ones
|
// Untracked files are deleted (git has no copy to restore); tracked ones
|
||||||
// go back to HEAD in both gutters, so a staged-then-edited file lands in
|
// go back to HEAD in both gutters, so a staged-then-edited file lands in
|
||||||
// one state rather than half-reverted.
|
// one state rather than half-reverted.
|
||||||
@@ -825,18 +832,11 @@ function hasHunks(body) {
|
|||||||
const MAX_PARTIAL_FILES = 300;
|
const MAX_PARTIAL_FILES = 300;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JetBrains' partial commit: whole files (`paths`) plus, per file in `hunks`,
|
* `body.hunks` checked for shape, and its files resolved as changed paths
|
||||||
* only the ticked change blocks — `{"<path>": [{hunk: "@@ … @@", blocks: [0]}]}`,
|
* that may be taken in part: tracked (EUNTRACKED), not a rename, not in
|
||||||
* the hunks named as `stage-hunk` names them, at `context`. Built in a
|
* conflict, and not also named whole in `body.paths`.
|
||||||
* TEMPORARY index (HEAD + the whole files + one patch per partial file from
|
|
||||||
* `selectionPatch`, git's own fresh text, never the request's) and committed
|
|
||||||
* from it, so the real index and the working tree are never in the way; after
|
|
||||||
* the commit the real index entries of exactly those paths are reset to the
|
|
||||||
* new HEAD, the rest of each partial file left as an unstaged change. Same
|
|
||||||
* path rules as `commitPaths`; a partial file must be tracked (EUNTRACKED)
|
|
||||||
* and not a rename, and a hunk the tree no longer has is EHUNKGONE.
|
|
||||||
*/
|
*/
|
||||||
async function commitSelection(dir, args, body) {
|
async function partialPicks(dir, body, verb) {
|
||||||
const picks = Object.entries(body.hunks);
|
const picks = Object.entries(body.hunks);
|
||||||
if (picks.length > MAX_PARTIAL_FILES) throw bad("EBADPATH", "too many files");
|
if (picks.length > MAX_PARTIAL_FILES) throw bad("EBADPATH", "too many files");
|
||||||
for (const [, list] of picks) {
|
for (const [, list] of picks) {
|
||||||
@@ -858,12 +858,54 @@ async function commitSelection(dir, args, body) {
|
|||||||
const conflicted = [...whole, ...part].find((p) => CONFLICT_CODES.has(p.code));
|
const conflicted = [...whole, ...part].find((p) => CONFLICT_CODES.has(p.code));
|
||||||
if (conflicted) throw bad("ECONFLICTS", `${conflicted.path} is in conflict: resolve it first`);
|
if (conflicted) throw bad("ECONFLICTS", `${conflicted.path} is in conflict: resolve it first`);
|
||||||
for (const p of part) {
|
for (const p of part) {
|
||||||
if (p.code === "??") throw bad("EUNTRACKED", `${p.path} is untracked: commit it whole`);
|
if (p.code === "??") throw bad("EUNTRACKED", `${p.path} is untracked: ${verb} it whole`);
|
||||||
if (p.code.includes("R")) throw bad("EBADPATH", `${p.path} is a rename: commit it whole`);
|
if (p.code.includes("R")) throw bad("EBADPATH", `${p.path} is a rename: ${verb} it whole`);
|
||||||
if (whole.some((w) => w.path === p.path)) {
|
if (whole.some((w) => w.path === p.path)) {
|
||||||
throw bad("EBADPATH", `${p.path} is named both whole and in part`);
|
throw bad("EBADPATH", `${p.path} is named both whole and in part`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { picks, whole, part };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The partial discard (#87): per file in `hunks`, only the ticked change
|
||||||
|
* blocks are undone off the working tree — `selectionPatch` in reverse, git's
|
||||||
|
* own fresh text, never the request's — exactly what one `discard-hunk` per
|
||||||
|
* block would do, in one step. Every patch is built before the first is
|
||||||
|
* applied, so a stale selection refuses (EHUNKGONE) with nothing undone.
|
||||||
|
*/
|
||||||
|
async function discardSelection(dir, body) {
|
||||||
|
const { picks } = await partialPicks(dir, body, "discard");
|
||||||
|
const patches = [];
|
||||||
|
for (const [p, list] of picks) {
|
||||||
|
const patch = await selectionPatch(dir, p, body.context, list, true);
|
||||||
|
if (!patch)
|
||||||
|
throw bad("EHUNKGONE", `${p}: that change is no longer in the diff; refresh and retry`);
|
||||||
|
patches.push(patch);
|
||||||
|
}
|
||||||
|
// The patch names paths from the repo root, so it is applied there.
|
||||||
|
const top = (await git(dir, ["rev-parse", "--show-toplevel"])).stdout.trim();
|
||||||
|
const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
|
||||||
|
const out = [];
|
||||||
|
for (const patch of patches)
|
||||||
|
out.push(await git(top, ["apply", "-R", ...zero, "-"], { input: patch }));
|
||||||
|
return mergeResults(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JetBrains' partial commit: whole files (`paths`) plus, per file in `hunks`,
|
||||||
|
* only the ticked change blocks — `{"<path>": [{hunk: "@@ … @@", blocks: [0]}]}`,
|
||||||
|
* the hunks named as `stage-hunk` names them, at `context`. Built in a
|
||||||
|
* TEMPORARY index (HEAD + the whole files + one patch per partial file from
|
||||||
|
* `selectionPatch`, git's own fresh text, never the request's) and committed
|
||||||
|
* from it, so the real index and the working tree are never in the way; after
|
||||||
|
* the commit the real index entries of exactly those paths are reset to the
|
||||||
|
* new HEAD, the rest of each partial file left as an unstaged change. Same
|
||||||
|
* path rules as `commitPaths`; a partial file must be tracked (EUNTRACKED)
|
||||||
|
* and not a rename, and a hunk the tree no longer has is EHUNKGONE.
|
||||||
|
*/
|
||||||
|
async function commitSelection(dir, args, body) {
|
||||||
|
const { picks, whole, part } = await partialPicks(dir, body, "commit");
|
||||||
const wholePaths = whole.flatMap((p) =>
|
const wholePaths = whole.flatMap((p) =>
|
||||||
p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]
|
p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -555,12 +555,13 @@ async function usage(t0, t1, p0, p1, mem) {
|
|||||||
|
|
||||||
const round = (n) => Math.round(n * 10) / 10;
|
const round = (n) => Math.round(n * 10) / 10;
|
||||||
|
|
||||||
/** A user's group fields: id, username and display name (every viewer reads them). */
|
/** A user's group fields: id, username, display name and avatar version (every viewer reads them). */
|
||||||
const userExtra = (u) => ({
|
const userExtra = (u) => ({
|
||||||
kind: "user",
|
kind: "user",
|
||||||
userId: u.id,
|
userId: u.id,
|
||||||
username: u.username,
|
username: u.username,
|
||||||
displayName: u.displayName ?? null,
|
displayName: u.displayName ?? null,
|
||||||
|
avatarAt: u.avatarAt ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
async function collect() {
|
async function collect() {
|
||||||
|
|||||||
+12
-6
@@ -5,7 +5,9 @@
|
|||||||
*
|
*
|
||||||
* A workspace (`lanes.owner_id`) belongs to the user who created it. A session
|
* A workspace (`lanes.owner_id`) belongs to the user who created it. A session
|
||||||
* belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of
|
* belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of
|
||||||
* that workspace holding it (a session not stamped yet). Agents, events and
|
* that workspace holding it (a session not stamped yet). A session no workspace owns
|
||||||
|
* belongs to the user of the Linux account it ran as (`sessions.account`,
|
||||||
|
* server/lib/session-account.js) — a script's `claude -p` is its runner's. Agents, events and
|
||||||
* token rows belong to their session. A developer sees and changes only what
|
* token rows belong to their session. A developer sees and changes only what
|
||||||
* their own workspaces own; an analyst SEES everything but changes only their
|
* their own workspaces own; an analyst SEES everything but changes only their
|
||||||
* own (every non-GET request is scoped like a developer's); an admin sees and
|
* own (every non-GET request is scoped like a developer's); an admin sees and
|
||||||
@@ -17,6 +19,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const sql = (q) => require("../db").db.prepare(q);
|
const sql = (q) => require("../db").db.prepare(q);
|
||||||
|
const { ACCOUNT_OWNED } = require("./session-account");
|
||||||
|
|
||||||
/** The user whose rows this request is limited to, or null for "all". */
|
/** The user whose rows this request is limited to, or null for "all". */
|
||||||
function memberId(req) {
|
function memberId(req) {
|
||||||
@@ -53,8 +56,9 @@ function ownedSessionWhere(uid, col, laneCol) {
|
|||||||
return {
|
return {
|
||||||
where: `(${laneCol} IN (SELECT id FROM lanes WHERE owner_id = ?)
|
where: `(${laneCol} IN (SELECT id FROM lanes WHERE owner_id = ?)
|
||||||
OR ${col} IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
OR ${col} IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||||
WHERE l.owner_id = ? AND r.session_id IS NOT NULL))`,
|
WHERE l.owner_id = ? AND r.session_id IS NOT NULL)
|
||||||
params: [uid, uid],
|
OR ${col} IN (SELECT session_id FROM (${ACCOUNT_OWNED}) WHERE uid = ?))`,
|
||||||
|
params: [uid, uid, uid],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,14 +268,16 @@ function ownerStillFits(laneId) {
|
|||||||
|
|
||||||
const STAFF = { staffOnly: true };
|
const STAFF = { staffOnly: true };
|
||||||
|
|
||||||
/** Owner ids of the workspaces that own a session (stamped or held by a run). */
|
/** Owner ids of a session: its workspace's (stamped or held by a run), else its account's user. */
|
||||||
function sessionOwners(sessionId) {
|
function sessionOwners(sessionId) {
|
||||||
if (!sessionId) return new Set();
|
if (!sessionId) return new Set();
|
||||||
const rows = sql(
|
const rows = sql(
|
||||||
`SELECT l.owner_id FROM sessions s JOIN lanes l ON l.id = s.lane_id WHERE s.id = ?
|
`SELECT l.owner_id FROM sessions s JOIN lanes l ON l.id = s.lane_id WHERE s.id = ?
|
||||||
UNION
|
UNION
|
||||||
SELECT l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.session_id = ?`
|
SELECT l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.session_id = ?
|
||||||
).all(String(sessionId), String(sessionId));
|
UNION
|
||||||
|
SELECT uid FROM (${ACCOUNT_OWNED}) WHERE session_id = ?`
|
||||||
|
).all(String(sessionId), String(sessionId), String(sessionId));
|
||||||
return new Set(rows.map((r) => r.owner_id).filter((x) => x != null));
|
return new Set(rows.map((r) => r.owner_id).filter((x) => x != null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* @file session-account.js
|
||||||
|
* @description Which Linux account a session ran as (`sessions.account`: ""
|
||||||
|
* = the host's, else an S3 member's name), and whose bill a session no
|
||||||
|
* workspace owns goes on. A workspace owns a session through `lane_id` or a
|
||||||
|
* `dashboard_runs` row (server/lib/scope.js); a session started OUTSIDE every
|
||||||
|
* workspace — a script's `claude -p`, a CLI typed in a plain terminal — has
|
||||||
|
* neither, and without this its spend counted for nobody: not in anyone's
|
||||||
|
* Today, the Users page or a spend quota.
|
||||||
|
*
|
||||||
|
* The account is the hook's PEER (server/lib/hook-auth.js: the uid of the
|
||||||
|
* loopback connection, never the body) when a hook creates the session; a
|
||||||
|
* session no hook made (the Codex/Antigravity/Cursor/omp pollers, history)
|
||||||
|
* gets it from its folder, the S3 rule `accountForPath` already applies to
|
||||||
|
* every pane. A pushed session (another machine's) never gets one. A member
|
||||||
|
* account bills to the user of that name; the host account to the admin an
|
||||||
|
* admin marked as its owner (`users.host_account`), or — with no mark — the
|
||||||
|
* only enabled admin, if there is exactly one.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const sql = (q) => require("../db").db.prepare(q);
|
||||||
|
|
||||||
|
// The user the host account's sessions bill to (null: nobody marked and not exactly one admin).
|
||||||
|
const HOST_OWNER = `COALESCE(
|
||||||
|
(SELECT MIN(id) FROM users WHERE host_account = 1 AND role = 'admin'),
|
||||||
|
(SELECT MIN(id) FROM users WHERE role = 'admin' AND disabled = 0 HAVING COUNT(*) = 1))`;
|
||||||
|
|
||||||
|
// A session no workspace owns: neither stamped onto an owned lane nor held by
|
||||||
|
// a run of one — exactly the complement of scope.js's ownership.
|
||||||
|
const UNOWNED = `(s.lane_id IS NULL OR s.lane_id NOT IN (SELECT id FROM lanes WHERE owner_id IS NOT NULL))
|
||||||
|
AND s.id NOT IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||||
|
WHERE r.session_id IS NOT NULL AND l.owner_id IS NOT NULL)`;
|
||||||
|
|
||||||
|
/** `session_id` → `uid` for every session billed by its account (no workspace owns it). */
|
||||||
|
const ACCOUNT_OWNED = `
|
||||||
|
SELECT s.id AS session_id, u.id AS uid
|
||||||
|
FROM sessions s JOIN users u
|
||||||
|
ON u.id = CASE WHEN s.account = '' THEN ${HOST_OWNER}
|
||||||
|
ELSE (SELECT id FROM users WHERE username = s.account) END
|
||||||
|
WHERE s.account IS NOT NULL AND ${UNOWNED}`;
|
||||||
|
|
||||||
|
/** The account a hook's peer names: "" for the host, a member's name, null otherwise. */
|
||||||
|
function accountOfPeer(peer) {
|
||||||
|
if (!peer || peer.kind === "host") return "";
|
||||||
|
if (peer.kind === "member" && peer.name) return peer.name;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a hook-created session's account — first writer wins, a pushed hook never writes. */
|
||||||
|
function stampFromPeer(sessionId, peer, pushSource = null) {
|
||||||
|
if (pushSource || !sessionId) return;
|
||||||
|
const account = accountOfPeer(peer);
|
||||||
|
if (account == null) return;
|
||||||
|
sql("UPDATE sessions SET account = ? WHERE id = ? AND account IS NULL").run(account, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The account a folder runs as: "" outside the users root, a member's name inside it. */
|
||||||
|
function accountOfPath(cwd) {
|
||||||
|
const linux = require("./linux-accounts");
|
||||||
|
const conf = linux.s3Enabled() ? linux.serverConf() : null;
|
||||||
|
const root = conf?.usersRoot;
|
||||||
|
const path = require("path");
|
||||||
|
if (!root || typeof cwd !== "string" || !path.isAbsolute(cwd)) return "";
|
||||||
|
const rel = path.relative(root, path.resolve(cwd));
|
||||||
|
if (rel.startsWith("..") || path.isAbsolute(rel)) return "";
|
||||||
|
// The users root itself is nobody's.
|
||||||
|
return rel.split(path.sep)[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give every local session still without an account the one its folder runs
|
||||||
|
* as (sessions the pollers made, and history from before the column). Cheap
|
||||||
|
* once caught up: only `account IS NULL` rows are read.
|
||||||
|
*/
|
||||||
|
function stampFromFolders() {
|
||||||
|
const rows = sql(
|
||||||
|
"SELECT id, cwd FROM sessions WHERE account IS NULL AND source NOT LIKE 'push:%' LIMIT 5000"
|
||||||
|
).all();
|
||||||
|
if (!rows.length) return 0;
|
||||||
|
const set = sql("UPDATE sessions SET account = ? WHERE id = ? AND account IS NULL");
|
||||||
|
let n = 0;
|
||||||
|
require("../db").db.transaction(() => {
|
||||||
|
for (const r of rows) {
|
||||||
|
const account = accountOfPath(r.cwd);
|
||||||
|
if (account != null) n += set.run(account, r.id).changes;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark `userId` (an admin) as the host account's owner, or clear the mark with null. */
|
||||||
|
function setHostOwner(userId) {
|
||||||
|
require("../db").db.transaction(() => {
|
||||||
|
sql("UPDATE users SET host_account = 0 WHERE host_account = 1").run();
|
||||||
|
if (userId != null) sql("UPDATE users SET host_account = 1 WHERE id = ?").run(userId);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user id the host account bills to right now, or null. */
|
||||||
|
function hostOwner() {
|
||||||
|
return sql(`SELECT ${HOST_OWNER} AS id`).get()?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ACCOUNT_OWNED,
|
||||||
|
accountOfPeer,
|
||||||
|
stampFromPeer,
|
||||||
|
accountOfPath,
|
||||||
|
stampFromFolders,
|
||||||
|
setHostOwner,
|
||||||
|
hostOwner,
|
||||||
|
};
|
||||||
@@ -6,7 +6,9 @@
|
|||||||
* swap it back in later, touching nothing but that login.
|
* swap it back in later, touching nothing but that login.
|
||||||
*
|
*
|
||||||
* - `codex`: the whole `$CODEX_HOME/auth.json` (a ChatGPT login or an API
|
* - `codex`: the whole `$CODEX_HOME/auth.json` (a ChatGPT login or an API
|
||||||
* key). Config, sessions, skills and history stay where they are.
|
* key). Config, sessions, skills and history stay where they are. A
|
||||||
|
* switch then restarts Codex's shared app-server daemon when one runs,
|
||||||
|
* so running and new sessions follow the new login (`follow`).
|
||||||
* - `omp`: every credential Oh My Pi stores, read with `omp auth export`
|
* - `omp`: every credential Oh My Pi stores, read with `omp auth export`
|
||||||
* and put back with `omp auth import -` (secrets on stdin, never argv).
|
* and put back with `omp auth import -` (secrets on stdin, never argv).
|
||||||
* Import MERGES, so a switch first empties omp's own credential tables in
|
* Import MERGES, so a switch first empties omp's own credential tables in
|
||||||
@@ -85,6 +87,15 @@ const codex = {
|
|||||||
writeJson(codexAuthFile(), secret);
|
writeJson(codexAuthFile(), secret);
|
||||||
return [codexAuthFile()];
|
return [codexAuthFile()];
|
||||||
},
|
},
|
||||||
|
// Codex ≥ 0.164 talks to the API through a shared app-server daemon that
|
||||||
|
// outlives every TUI and holds the login it started with: a new auth.json
|
||||||
|
// reached neither a running session nor a NEW one, which kept the old
|
||||||
|
// (often out-of-quota) account. Restarting the daemon makes it read the new
|
||||||
|
// login, and every open TUI reconnects to it on its own (verified on
|
||||||
|
// 0.164.0). No daemon running: nothing to restart — the next one reads the file.
|
||||||
|
follow() {
|
||||||
|
return restartCodexDaemon();
|
||||||
|
},
|
||||||
identify(auth) {
|
identify(auth) {
|
||||||
if (auth.tokens && auth.tokens.id_token) {
|
if (auth.tokens && auth.tokens.id_token) {
|
||||||
const c = jwtClaims(auth.tokens.id_token) || {};
|
const c = jwtClaims(auth.tokens.id_token) || {};
|
||||||
@@ -107,6 +118,26 @@ const codex = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The control socket's link points into a per-user runtime dir that exists only while a daemon runs. */
|
||||||
|
const codexDaemonSocket = () =>
|
||||||
|
path.join(getCodexHome(), "app-server-control", "app-server-control.sock");
|
||||||
|
|
||||||
|
/** Restart the CODEX_HOME's app-server daemon when one is running; true when a restart was started. */
|
||||||
|
function restartCodexDaemon() {
|
||||||
|
if (!fs.existsSync(codexDaemonSocket())) return false;
|
||||||
|
const bin = process.env.AGENTHUB_CODEX_BIN || require("./ai-tools").detect("codex").path;
|
||||||
|
if (!bin) return false;
|
||||||
|
cp.execFile(
|
||||||
|
bin,
|
||||||
|
["app-server", "daemon", "restart"],
|
||||||
|
{ env: { ...process.env, CODEX_HOME: getCodexHome() }, timeout: 60000, windowsHide: true },
|
||||||
|
(e) => {
|
||||||
|
if (e) console.warn(`[tool-profiles] codex daemon restart failed: ${e.message}`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Oh My Pi ───────────────────────────────────────────────────────────
|
// ── Oh My Pi ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
|
const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
|
||||||
@@ -405,7 +436,9 @@ function activateProfile(tool, name) {
|
|||||||
}
|
}
|
||||||
const target = readProfile(tool, name);
|
const target = readProfile(tool, name);
|
||||||
const written = a.write(target.secret, live);
|
const written = a.write(target.secret, live);
|
||||||
return { ok: true, name, savedOutgoing, written };
|
// `following`: running sessions take the new login without a restart of their own.
|
||||||
|
const following = typeof a.follow === "function" ? a.follow() : false;
|
||||||
|
return { ok: true, name, savedOutgoing, written, following };
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteProfile(tool, name) {
|
function deleteProfile(tool, name) {
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ const paths = {
|
|||||||
summary: "Skills from files: a file, a folder, a .zip/.skill or several skills",
|
summary: "Skills from files: a file, a folder, a .zip/.skill or several skills",
|
||||||
operationId: "uploadArtifactSkills",
|
operationId: "uploadArtifactSkills",
|
||||||
description:
|
description:
|
||||||
"`paths` is a JSON array of each file's relative path. Zip sizes are read before inflating; nothing is stored if any skill fails.",
|
"`paths` is a JSON array of each file's relative path. Zip sizes are read before inflating; nothing is stored if any skill fails. A skill whose name matches one the caller already has (not a workspace's) becomes that row's new version (`replaced: true`, `changed`) instead of a second row; `id` (Share again) puts the ONE uploaded skill onto that row, under the row's name.",
|
||||||
requestBody: multipart(
|
requestBody: multipart(
|
||||||
{
|
{
|
||||||
files: { type: "array", items: { type: "string", format: "binary" } },
|
files: { type: "array", items: { type: "string", format: "binary" } },
|
||||||
@@ -246,6 +246,7 @@ const paths = {
|
|||||||
name: { type: "string" },
|
name: { type: "string" },
|
||||||
title: { type: "string" },
|
title: { type: "string" },
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
|
id: { type: "string", description: "Share again: the skill row the upload replaces." },
|
||||||
},
|
},
|
||||||
["files"]
|
["files"]
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -790,6 +790,11 @@ const paths = {
|
|||||||
addWorkspaces: { type: "array", items: { type: "integer" } },
|
addWorkspaces: { type: "array", items: { type: "integer" } },
|
||||||
removeWorkspaces: { type: "array", items: { type: "integer" } },
|
removeWorkspaces: { type: "array", items: { type: "integer" } },
|
||||||
signOut: { type: "boolean", enum: [true] },
|
signOut: { type: "boolean", enum: [true] },
|
||||||
|
hostAccount: {
|
||||||
|
type: "boolean",
|
||||||
|
description:
|
||||||
|
"An admin owns the host's Linux account: its sessions outside every workspace are billed to them (at most one; EBADROLE for a non-admin)",
|
||||||
|
},
|
||||||
quota: { $ref: "#/components/schemas/UserQuotaSetting" },
|
quota: { $ref: "#/components/schemas/UserQuotaSetting" },
|
||||||
}),
|
}),
|
||||||
responses: {
|
responses: {
|
||||||
|
|||||||
@@ -1113,6 +1113,26 @@ const paths = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/artifacts/{id}/reshare": {
|
||||||
|
post: {
|
||||||
|
tags: ["Artifacts"],
|
||||||
|
summary: "Share again: a skill's newest version from its owner's folder on this server",
|
||||||
|
operationId: "reshareArtifact",
|
||||||
|
description:
|
||||||
|
"The owner's skill folder (the row's source folder, else the same-named skill of a plugin the owner installed at user scope) becomes the new snapshot, and the row is shared (server mode); installers are then offered Update. 409 ENOSOURCE when the server has no copy: upload the folder with `id` on /api/artifacts/skill/upload. Same-origin only.",
|
||||||
|
parameters: [pathParam("id", "Artifact id.")],
|
||||||
|
responses: {
|
||||||
|
200: ok("Taken.", {
|
||||||
|
item: { type: "object" },
|
||||||
|
changed: { type: "boolean" },
|
||||||
|
}),
|
||||||
|
400: errs("EBADKIND."),
|
||||||
|
403: errs("EFORBIDDEN."),
|
||||||
|
404: errs("ENOTFOUND."),
|
||||||
|
409: errs("ENOSOURCE."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/artifacts/{id}/claim": {
|
"/api/artifacts/{id}/claim": {
|
||||||
post: {
|
post: {
|
||||||
tags: ["Artifacts"],
|
tags: ["Artifacts"],
|
||||||
|
|||||||
@@ -489,6 +489,11 @@ const paths = {
|
|||||||
items: { type: "string" },
|
items: { type: "string" },
|
||||||
description: "Files the switch wrote.",
|
description: "Files the switch wrote.",
|
||||||
},
|
},
|
||||||
|
following: {
|
||||||
|
type: "boolean",
|
||||||
|
description:
|
||||||
|
"Codex: a running app-server daemon was restarted, so open and new sessions use the new login.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"Switched."
|
"Switched."
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const STATUS = {
|
|||||||
ENEEDLOGIN: 502,
|
ENEEDLOGIN: 502,
|
||||||
ETOOBIG: 413,
|
ETOOBIG: 413,
|
||||||
EOWNED: 409,
|
EOWNED: 409,
|
||||||
|
ENOSOURCE: 409,
|
||||||
};
|
};
|
||||||
|
|
||||||
function sendError(res, err) {
|
function sendError(res, err) {
|
||||||
@@ -229,6 +230,7 @@ router.post(
|
|||||||
name: b.name,
|
name: b.name,
|
||||||
title: b.title,
|
title: b.title,
|
||||||
description: b.description,
|
description: b.description,
|
||||||
|
id: b.id,
|
||||||
});
|
});
|
||||||
for (const item of items) changed(item);
|
for (const item of items) changed(item);
|
||||||
res.status(201).json({ items });
|
res.status(201).json({ items });
|
||||||
@@ -284,6 +286,19 @@ router.patch(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Share again: the owner's skill folder on this server becomes the new
|
||||||
|
// version (installers see Update); 409 ENOSOURCE when the server has none —
|
||||||
|
// the client then uploads the folder onto this row (`/skill/upload` + `id`).
|
||||||
|
router.post(
|
||||||
|
"/:id/reshare",
|
||||||
|
sameOriginGuard,
|
||||||
|
wrap((req, res) => {
|
||||||
|
const out = artifacts.reshareSkill(req, req.params.id);
|
||||||
|
changed(out.item);
|
||||||
|
res.json(out);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
router.delete(
|
router.delete(
|
||||||
"/:id",
|
"/:id",
|
||||||
sameOriginGuard,
|
sameOriginGuard,
|
||||||
|
|||||||
@@ -1643,6 +1643,8 @@ function ingestHook(body, pushSource = null, peer = { kind: "host" }) {
|
|||||||
} else {
|
} else {
|
||||||
touchLaneFromHook(hook_type, data, tool, body.run_id, peer);
|
touchLaneFromHook(hook_type, data, tool, body.run_id, peer);
|
||||||
bindRunSession(body.run_id, data.session_id, hook_type, data.source, tool);
|
bindRunSession(body.run_id, data.session_id, hook_type, data.source, tool);
|
||||||
|
// The Linux account it ran as: whose bill it is when no workspace owns it.
|
||||||
|
require("../lib/session-account").stampFromPeer(data.session_id, peer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ const { recordAccountChange } = require("../lib/audit");
|
|||||||
const ACCOUNT_STATUS = {
|
const ACCOUNT_STATUS = {
|
||||||
EBADFIELD: 400,
|
EBADFIELD: 400,
|
||||||
EBADEMAIL: 400,
|
EBADEMAIL: 400,
|
||||||
|
ENAMETAKEN: 409,
|
||||||
EBADPASSWORD: 400,
|
EBADPASSWORD: 400,
|
||||||
EWEAKPASSWORD: 400,
|
EWEAKPASSWORD: 400,
|
||||||
EBADIMAGE: 400,
|
EBADIMAGE: 400,
|
||||||
@@ -262,6 +263,7 @@ router.patch(
|
|||||||
sameOriginGuard,
|
sameOriginGuard,
|
||||||
accountWrite(async (_req, user, body) => {
|
accountWrite(async (_req, user, body) => {
|
||||||
const fields = auth.validateProfile({ displayName: body.displayName, email: body.email });
|
const fields = auth.validateProfile({ displayName: body.displayName, email: body.email });
|
||||||
|
auth.assertNameFree(user.id, fields);
|
||||||
auth.setProfile(user.id, fields);
|
auth.setProfile(user.id, fields);
|
||||||
return { out: accountView(user.id), action: "own profile" };
|
return { out: accountView(user.id), action: "own profile" };
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -702,7 +702,7 @@ router.get("/:id/file", async (req, res) => {
|
|||||||
function sessionStats(sessionId) {
|
function sessionStats(sessionId) {
|
||||||
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
|
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
|
||||||
const eventsByType = stmts.sessionEventTypeCounts.all(sessionId);
|
const eventsByType = stmts.sessionEventTypeCounts.all(sessionId);
|
||||||
const tools = stmts.sessionToolUsageCounts.all(sessionId);
|
const tools = stmts.sessionToolUsageCounts.all(sessionId, sessionId);
|
||||||
// Exact totals: `tools_used` is a top-15 list, so its sum is not the count.
|
// Exact totals: `tools_used` is a top-15 list, so its sum is not the count.
|
||||||
// A tool call is a PreToolUse; a session with none (imported) counts PostToolUse.
|
// A tool call is a PreToolUse; a session with none (imported) counts PostToolUse.
|
||||||
const toolTotals = stmts.sessionToolCallTotals.get(sessionId) || {};
|
const toolTotals = stmts.sessionToolCallTotals.get(sessionId) || {};
|
||||||
|
|||||||
+24
-3
@@ -54,8 +54,9 @@ const { listAudit, recordAccountChange } = require("../lib/audit");
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// Which account each session belongs to — through its workspace, or a
|
// Which account each session belongs to — through its workspace, a dashboard
|
||||||
// dashboard run on one (server/lib/scope.js's sessionWhere, for every user at once).
|
// run on one, or (no workspace owns it) the Linux account it ran as
|
||||||
|
// (server/lib/scope.js's sessionWhere, for every user at once).
|
||||||
const OWNED_SESSIONS = `
|
const OWNED_SESSIONS = `
|
||||||
SELECT s.id AS session_id, l.owner_id AS uid
|
SELECT s.id AS session_id, l.owner_id AS uid
|
||||||
FROM sessions s JOIN lanes l ON l.id = s.lane_id
|
FROM sessions s JOIN lanes l ON l.id = s.lane_id
|
||||||
@@ -63,7 +64,9 @@ const OWNED_SESSIONS = `
|
|||||||
UNION
|
UNION
|
||||||
SELECT s.id, l.owner_id
|
SELECT s.id, l.owner_id
|
||||||
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id JOIN sessions s ON s.id = r.session_id
|
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id JOIN sessions s ON s.id = r.session_id
|
||||||
WHERE l.owner_id IS NOT NULL`;
|
WHERE l.owner_id IS NOT NULL
|
||||||
|
UNION
|
||||||
|
${require("../lib/session-account").ACCOUNT_OWNED}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map(user id → {cost, tokens}) per window, each window a UTC hour
|
* Map(user id → {cost, tokens}) per window, each window a UTC hour
|
||||||
@@ -171,6 +174,7 @@ router.get("/tmux", (_req, res) => {
|
|||||||
const STATUS = {
|
const STATUS = {
|
||||||
EBADUSER: 400,
|
EBADUSER: 400,
|
||||||
EBADEMAIL: 400,
|
EBADEMAIL: 400,
|
||||||
|
ENAMETAKEN: 409,
|
||||||
ENOTOWNED: 409,
|
ENOTOWNED: 409,
|
||||||
EBADPASSWORD: 400,
|
EBADPASSWORD: 400,
|
||||||
EWEAKPASSWORD: 400,
|
EWEAKPASSWORD: 400,
|
||||||
@@ -562,6 +566,7 @@ router.patch(
|
|||||||
|
|
||||||
// 1. Validate everything — nothing is written until all of it passes.
|
// 1. Validate everything — nothing is written until all of it passes.
|
||||||
const profile = auth.validateProfile(body);
|
const profile = auth.validateProfile(body);
|
||||||
|
auth.assertNameFree(u.id, profile);
|
||||||
const roleChange = body.role !== undefined && body.role !== u.role;
|
const roleChange = body.role !== undefined && body.role !== u.role;
|
||||||
if (roleChange) {
|
if (roleChange) {
|
||||||
if (self) throw ESELF("you cannot change your own role");
|
if (self) throw ESELF("you cannot change your own role");
|
||||||
@@ -578,6 +583,18 @@ router.patch(
|
|||||||
if (self && body.disabled) throw ESELF("you cannot disable yourself");
|
if (self && body.disabled) throw ESELF("you cannot disable yourself");
|
||||||
}
|
}
|
||||||
const disabledChange = body.disabled !== undefined && body.disabled !== Boolean(u.disabled);
|
const disabledChange = body.disabled !== undefined && body.disabled !== Boolean(u.disabled);
|
||||||
|
if (body.hostAccount !== undefined) {
|
||||||
|
if (typeof body.hostAccount !== "boolean") {
|
||||||
|
throw Object.assign(new Error("hostAccount: true or false"), { code: "EBADFIELD" });
|
||||||
|
}
|
||||||
|
if (body.hostAccount && (body.role ?? u.role) !== "admin") {
|
||||||
|
throw Object.assign(new Error("only an admin can own the host account"), {
|
||||||
|
code: "EBADROLE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hostChange =
|
||||||
|
body.hostAccount !== undefined && body.hostAccount !== Boolean(u.host_account);
|
||||||
if (body.password !== undefined) {
|
if (body.password !== undefined) {
|
||||||
// Your own password: every rule. Someone else's: a starting one they replace.
|
// Your own password: every rule. Someone else's: a starting one they replace.
|
||||||
if (self) {
|
if (self) {
|
||||||
@@ -617,6 +634,10 @@ router.patch(
|
|||||||
auth.setRole(u.username, body.role);
|
auth.setRole(u.username, body.role);
|
||||||
done.push(`role ${body.role}`);
|
done.push(`role ${body.role}`);
|
||||||
}
|
}
|
||||||
|
if (hostChange) {
|
||||||
|
require("../lib/session-account").setHostOwner(body.hostAccount ? u.id : null);
|
||||||
|
done.push(body.hostAccount ? "owns the host account" : "no longer owns the host account");
|
||||||
|
}
|
||||||
for (const id of add) auth.assignWorkspace(id, u.username);
|
for (const id of add) auth.assignWorkspace(id, u.username);
|
||||||
for (const id of remove) auth.unassignWorkspace(id, u.username);
|
for (const id of remove) auth.unassignWorkspace(id, u.username);
|
||||||
if (add.length) done.push(`workspace ${add.join(", ")}`);
|
if (add.length) done.push(`workspace ${add.join(", ")}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user