chore: v2.8.12 — clone queue and progress, session defaults, chat agents, drops as @ mentions

Whole tree from parallel sessions, plus the version bump:
- Clone: a queue with live progress (Add workspace and extra folders),
  clones run under nice/ionice so the machine stays responsive.
- Settings → AI tools → Session defaults: a new session's default
  permission mode / bypass per account.
- Chat lists the background subagents that have not reported back yet.
- A file pasted or dropped into a claude pane goes in as an @ mention.
- Changes panel: say why amend/undo is refused on a pushed commit.
- openapi.yaml regenerated (Artifacts, Host, resumeById).
This commit is contained in:
2026-10-19 17:19:41 +07:00
parent 1a6b550e89
commit 49f87c4da3
61 changed files with 2824 additions and 114 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -165,12 +165,15 @@ export function MenuItem({
danger,
disabled,
testId,
title,
children,
}: {
onSelect: () => void;
danger?: boolean;
disabled?: boolean;
testId?: string;
/** Hover text — e.g. why a disabled item cannot run. */
title?: string;
children: ReactNode;
}) {
return (
@@ -179,6 +182,7 @@ export function MenuItem({
role="menuitem"
data-testid={testId}
disabled={disabled}
title={title}
className={`${MENU_ITEM}${danger ? " !text-status-danger" : ""}`}
onClick={onSelect}
>
+1 -1
View File
@@ -318,7 +318,7 @@ export function PetChat({
const r = await startInFreeTabDetailed(lane.id, {
prompt,
...(tool !== lane.tool ? { tool } : {}),
...(tool === "claude" ? { permissionMode: readDefaultMode() } : {}),
...(tool === "claude" ? { permissionMode: readDefaultMode({ safe: true }) } : {}),
});
remember({ laneId: r.laneId, runId: r.runId, tool });
} catch (err) {
@@ -17,7 +17,9 @@
* A caller that knows the session has ended passes `ended`: nothing more is
* being written, so the catch-up poll drops from 3 s to 60 s and the
* sub-agent list stops being rescanned — a `new_event` frame, a reconnect or
* the view being shown again still fetch the tail at once.
* the view being shown again still fetch the tail at once. The main
* transcript hands MessageList its `sessionId`, so the list can read its
* background agents' own transcripts for their progress.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -622,6 +624,9 @@ function ConversationViewPane({
waiting={waiting}
onOpenTerminal={onOpenTerminal}
stopped={stopped}
sessionId={
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
}
/>
)}
</div>
@@ -26,7 +26,14 @@
* terminal (`waiting`) — and, like the terminal's status line, how long the
* turn has run and the output tokens it has made; a call running long shows
* its own time, and a line silent for minutes turns amber. A turn that ended
* closes with how long it took and its output.
* closes with how long it took and its output. A live chat also lists the
* subagents it launched in the background that have not reported back yet
* (an `Agent` call whose result is "Async agent launched…" and no
* `<task-notification>` naming its call since), like the terminal's footer:
* their turn returns at once, so without the list a turn that ended reads
* "Done" while they still work (issue #81) — it then reads "Replied in …".
* Given the `sessionId`, each line also shows what that agent is doing now
* and how large its context is, read off the tail of its own transcript.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -101,6 +108,7 @@ import {
CornerDownRight,
} from "lucide-react";
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
import { api } from "../../lib/api";
/** Per-sender visual treatment for a transcript row. A JSONL `type:"user"` line
* is not always the human (tool results, harness task-notifications, the
@@ -163,6 +171,9 @@ interface MessageListProps {
/** Bumped by the chat's Stop: the turn on screen is over, even when the
* transcript never says so. */
stopped?: number;
/** The session whose MAIN transcript this is: its background agents'
* own transcripts are read for their progress. */
sessionId?: string;
}
/** Build a map from tool_use id → tool_result for matching */
@@ -641,6 +652,7 @@ function MessageListView({
waiting = false,
onOpenTerminal,
stopped = 0,
sessionId,
}: MessageListProps) {
const { t } = useTranslation("sessions");
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
@@ -659,6 +671,10 @@ function MessageListView({
);
const stats = useMemo(() => (pending ? turnStats(messages) : null), [pending, messages]);
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
const agents = useMemo(
() => (live ? backgroundAgents(messages, toolResultMap) : []),
[live, messages, toolResultMap]
);
if (loading) {
return (
@@ -715,7 +731,14 @@ function MessageListView({
/>
);
case "done":
return <TurnDone key={item.key} ms={item.ms} output={item.output} />;
return (
<TurnDone
key={item.key}
ms={item.ms}
output={item.output}
agents={item === items[items.length - 1] ? agents.length : 0}
/>
);
}
})}
{pending && (
@@ -727,6 +750,7 @@ function MessageListView({
onOpenTerminal={onOpenTerminal}
/>
)}
{agents.length > 0 && <BackgroundAgents agents={agents} sessionId={sessionId} />}
</div>
</OpenFileContext.Provider>
);
@@ -783,6 +807,48 @@ export function workingOn(
return alive ? { blocks, since: null, last } : null;
}
// A launch older than this with no notification is from a run that ended
// without one (the CLI quit, a resume of an old session): not running now.
const AGENT_STALE_MS = 6 * 60 * 60_000;
const NOTIFIED_CALL = /<tool-use-id>([^<]+)<\/tool-use-id>/g;
// The launch result names the agent's own transcript (`agent-<id>.jsonl`).
const LAUNCHED_ID = /agentId:\s*([\w-]+)/;
type BackgroundAgent = { block: TranscriptContent; since: string | null; agentId: string | null };
/** The subagents launched in the background that have not reported back: an
* Agent call whose result says it was launched async, with no
* `<task-notification>` naming that call since (any status — completed,
* failed, killed — means it stopped). `since` is its launch time. */
export function backgroundAgents(
messages: TranscriptMessage[],
results: Map<string, TranscriptContent>,
now = Date.now()
): BackgroundAgent[] {
const running = new Map<string, BackgroundAgent>();
for (const msg of messages) {
if (msg.type === "assistant") {
for (const b of msg.content) {
if (b.type !== "tool_use" || !b.id || (b.name !== "Agent" && b.name !== "Task")) continue;
// The output is the result content as JSON when it is a block list.
const output = results.get(b.id)?.output;
if (!output?.includes("Async agent launched")) continue;
const at = msg.timestamp ?? null;
if (at && now - parseDate(at).getTime() > AGENT_STALE_MS) continue;
running.set(b.id, { block: b, since: at, agentId: output.match(LAUNCHED_ID)?.[1] ?? null });
}
continue;
}
if (!running.size) continue;
for (const c of msg.content) {
if (c.type !== "text" || !c.text || !isTaskNotification(c.text)) continue;
for (const m of c.text.matchAll(NOTIFIED_CALL)) running.delete(m[1]!.trim());
}
}
return [...running.values()];
}
/** Output tokens counted once per API call: Claude Code writes one line per
* content block, each repeating its call's usage — consecutive lines with
* the same prompt size are one call. */
@@ -857,6 +923,23 @@ function useCountUp(value: number): number {
return shown;
}
/** The time now, ticking every second while `ref`'s box is on screen: a
* hidden tab or a hidden pane (another workspace, kept mounted) shows no
* clock, so the re-render is skipped until it is shown again. */
function useClock(ref: React.RefObject<HTMLElement | null>): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => {
const el = ref.current;
if (document.visibilityState !== "visible" || (el && el.getClientRects().length === 0))
return;
setNow(Date.now());
}, 1000);
return () => window.clearInterval(timer);
}, [ref]);
return now;
}
/** A call's one-line title: its description (Claude Code's Bash
* `description`), else the tool and its target. */
function stepTitle(block: TranscriptContent): string {
@@ -886,20 +969,9 @@ function RunningStep({
onOpenTerminal?: () => void;
}) {
const { t } = useTranslation("sessions");
const [now, setNow] = useState(() => Date.now());
const [showAll, setShowAll] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// A hidden tab or a hidden pane (another workspace, kept mounted) shows
// no clock: skip the re-render until it is on screen again.
const timer = window.setInterval(() => {
const el = boxRef.current;
if (document.visibilityState !== "visible" || (el && el.getClientRects().length === 0))
return;
setNow(Date.now());
}, 1000);
return () => window.clearInterval(timer);
}, []);
const now = useClock(boxRef);
const counted = useCountUp(tokens);
const age = (at: string | null) => (at ? now - parseDate(at).getTime() : 0);
const first = blocks[0];
@@ -976,7 +1048,159 @@ function RunningStep({
);
}
/** Under a turn that ended: how long it took and the output it made. */
// How often a running agent's transcript tail is re-read, and how much of it
// is kept: enough for its newest step and its newest usage.
const AGENT_TAIL_MS = 4000;
const AGENT_TAIL_KEEP = 40;
/** The newest lines of each agent's own transcript (by its `agentId`),
* re-read while `ref`'s box is on screen — only the lines after those
* already read (`after`). A failed read leaves the line without detail. */
function useAgentTails(
sessionId: string | undefined,
ids: string[],
ref: React.RefObject<HTMLElement | null>
): Map<string, TranscriptMessage[]> {
const [tails, setTails] = useState(() => new Map<string, TranscriptMessage[]>());
const lastRef = useRef(new Map<string, number>());
const key = ids.join(",");
useEffect(() => {
if (!sessionId || !key) return;
let cancelled = false;
let busy = false;
const read = async () => {
const el = ref.current;
if (
busy ||
document.visibilityState !== "visible" ||
(el && el.getClientRects().length === 0)
)
return;
busy = true;
await Promise.all(
key.split(",").map(async (id) => {
const last = lastRef.current.get(id);
try {
const r = await api.sessions.transcript(sessionId, {
agent_id: id,
...(last ? { after: last } : { limit: 20 }),
});
if (cancelled || !r.messages.length) return;
lastRef.current.set(id, r.last_line);
setTails((prev) => {
const next = new Map(prev);
next.set(id, [...(prev.get(id) ?? []), ...r.messages].slice(-AGENT_TAIL_KEEP));
return next;
});
} catch {
// Non-fatal: the line keeps its description and time.
}
})
);
busy = false;
};
void read();
const timer = window.setInterval(() => void read(), AGENT_TAIL_MS);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [sessionId, key, ref]);
return tails;
}
/** What an agent's transcript tail says it is doing: the call it waits on
* (or none while it thinks), and its newest prompt size — its context. */
export function agentProgress(
messages: TranscriptMessage[],
now = Date.now()
): { step: string | null; context: number } {
let context = 0;
for (let i = messages.length - 1; i >= 0 && !context; i--) {
const u = messages[i]!.usage;
if (messages[i]!.type === "assistant" && u) context = promptTokens(u);
}
const first = workingOn(messages, buildToolResultMap(messages), now)?.blocks[0];
return { step: first ? stepTitle(first) : null, context };
}
/** The subagents still working in the background, one line each like the
* terminal's footer: its type, its task and how long it has run — with its
* step now and its context once its own transcript was read. */
function BackgroundAgents({
agents,
sessionId,
}: {
agents: BackgroundAgent[];
sessionId?: string;
}) {
const { t } = useTranslation("sessions");
const boxRef = useRef<HTMLDivElement>(null);
const now = useClock(boxRef);
const ids = useMemo(() => agents.flatMap((a) => (a.agentId ? [a.agentId] : [])), [agents]);
const tails = useAgentTails(sessionId, ids, boxRef);
return (
<div
ref={boxRef}
data-testid="chat-background-agents"
className="overflow-hidden rounded-xl border border-border text-sm"
>
<div className="flex items-center gap-2 border-b border-border bg-surface-1 px-3 py-2 text-fg-primary">
<Bot className="h-3.5 w-3.5 text-fg-muted" />
<span className="flex-1">{t("conversation.agentsRunning", { count: agents.length })}</span>
</div>
<ul className="divide-y divide-border">
{agents.map(({ block, since, agentId }, i) => {
const input = (block.input ?? {}) as Record<string, unknown>;
const type = typeof input.subagent_type === "string" ? input.subagent_type : "Agent";
const desc = typeof input.description === "string" ? input.description : "";
const tail = agentId ? tails.get(agentId) : undefined;
const { step, context } = tail ? agentProgress(tail, now) : { step: null, context: 0 };
return (
<li
key={block.id ?? i}
data-testid="chat-background-agent"
className="px-3 py-2 text-fg-secondary"
>
<div className="flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />
<span className="flex-shrink-0 rounded bg-surface-3/60 px-1.5 py-0.5 font-mono text-xs text-fg-muted">
{type}
</span>
<span className="min-w-0 flex-1 truncate" title={desc}>
{desc}
</span>
{[
since && formatMs(now - parseDate(since).getTime()),
context > 0 && `↓ ${t("conversation.agentContext", { tokens: fmt(context) })}`,
]
.filter(Boolean)
.map((v, j) => (
<span key={j} className="flex-shrink-0 font-mono text-xs text-fg-muted">
{v}
</span>
))}
</div>
{tail && (
<div
data-testid="chat-background-agent-step"
className="ml-[1.375rem] mt-0.5 flex items-center gap-1 text-xs text-fg-muted"
>
<CornerDownRight className="h-3 w-3 flex-shrink-0" />
<span className="min-w-0 truncate">{step ?? t("conversation.working")}</span>
</div>
)}
</li>
);
})}
</ul>
</div>
);
}
/** Under a turn that ended: how long it took and the output it made — or,
* while subagents it launched still work (`agents`), that only the reply is
* done. */
/** An option's label carries the model's " (Recommended)" marker into the
* answer the CLI records; the user's pick reads without it (every picked
* label of a multi-select answer too). */
@@ -1028,14 +1252,21 @@ function AskCard({
);
}
function TurnDone({ ms, output }: { ms: number; output: number }) {
function TurnDone({ ms, output, agents = 0 }: { ms: number; output: number; agents?: number }) {
const { t } = useTranslation("sessions");
return (
<div data-testid="chat-turn-done" className="flex items-center gap-1.5 text-xs text-fg-muted">
<Check className="h-3 w-3 flex-shrink-0" />
{agents > 0 ? (
<Loader2 className="h-3 w-3 flex-shrink-0 animate-spin text-accent" />
) : (
<Check className="h-3 w-3 flex-shrink-0" />
)}
{[
t("conversation.turnDone", { time: formatMs(ms) }),
t(agents > 0 ? "conversation.turnReplied" : "conversation.turnDone", {
time: formatMs(ms),
}),
output > 0 && t("conversation.turnTokens", { tokens: fmt(output) }),
agents > 0 && t("conversation.agentsStill", { count: agents }),
]
.filter(Boolean)
.join(" · ")}
@@ -1045,8 +1276,8 @@ function TurnDone({ ms, output }: { ms: number; output: number }) {
const meta = "text-[10px] font-mono text-fg-muted";
// What the prompt box sends for an attached file: its saved path under the
// drop dir (`<data dir>/drops/<16 hex>[-name].ext`, server/routes/run.js; the
// What the prompt box sends for an attached file: its saved path (an `@`
// mention for claude) under the drop dir (`<data dir>/drops/<16 hex>[-name].ext`, server/routes/run.js; the
// older `<tmp>/agenthub-drops-<uid>/` still in past transcripts).
const DROP_PATH =
/(?<=^|\s)(\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+))(?=\s|$)/gi;
@@ -1066,7 +1297,7 @@ export function splitDropImages(text: string): {
const files: string[] = [];
const rest = text.replace(DROP_PATH, (_all, full: string, name: string) => {
if (IMAGE_EXT.test(name)) images.push(name);
else files.push(full);
else files.push(full.replace(/^@/, "")); // a claude prompt's `@` mention
return "";
});
return {
@@ -0,0 +1,118 @@
/**
* @file MessageList.agents.test.tsx
* @description Verifies a live chat's background-agents card reads each
* running agent's own transcript (by the agentId its launch result names)
* and shows the step it is on and its context size (issue #81).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MessageList, agentProgress } from "../MessageList";
import { api } from "../../../lib/api";
import type { TranscriptMessage } from "../../../lib/types";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { sessions: { transcript: vi.fn() } } };
});
const at = new Date().toISOString();
const m = (p: Partial<TranscriptMessage>): TranscriptMessage =>
({ type: "user", timestamp: at, content: [], ...p }) as TranscriptMessage;
// The subagent's own transcript: its task, then a search still running.
const tail: TranscriptMessage[] = [
m({ sender: "orchestrator", content: [{ type: "text", text: "Find copyId" }] }),
m({
type: "assistant",
sender: "assistant",
stop_reason: "tool_use",
usage: { input_tokens: 300, output_tokens: 20, cache_read_input_tokens: 54_000 },
content: [
{
type: "tool_use",
id: "g1",
name: "Grep",
input: { pattern: "copyId", description: "Searching for copyId" },
},
],
}),
];
const main: TranscriptMessage[] = [
m({ sender: "user", content: [{ type: "text", text: "go" }] }),
m({
type: "assistant",
sender: "assistant",
stop_reason: "tool_use",
content: [
{
type: "tool_use",
id: "t1",
name: "Agent",
input: { description: "Checking copyId coverage", subagent_type: "Explore" },
},
],
}),
m({
content: [
{
type: "tool_result",
id: "t1",
output: JSON.stringify([
{ type: "text", text: "Async agent launched successfully.\nagentId: a4118367b503adecc" },
]),
},
],
}),
m({
type: "assistant",
sender: "assistant",
stop_reason: "end_turn",
content: [{ type: "text", text: "waiting" }],
}),
];
describe("background agents' progress", () => {
const rects = Element.prototype.getClientRects;
beforeEach(() => {
// jsdom lays nothing out: make the card count as on screen.
Element.prototype.getClientRects = () => [{}] as unknown as DOMRectList;
vi.mocked(api.sessions.transcript).mockResolvedValue({
messages: tail,
total: 2,
has_more: false,
first_line: 1,
last_line: 2,
});
});
afterEach(() => {
Element.prototype.getClientRects = rects;
vi.mocked(api.sessions.transcript).mockReset();
});
it("reads the step an agent is on and its context", () => {
expect(agentProgress(tail)).toEqual({ step: "Searching for copyId", context: 54_300 });
expect(agentProgress(tail.slice(0, 1))).toEqual({ step: null, context: 0 });
});
it("shows them on the agent's line, from its own transcript", async () => {
render(<MessageList live loading={false} messages={main} sessionId="s1" />);
await waitFor(() =>
expect(screen.getByTestId("chat-background-agent-step")).toHaveTextContent(
"Searching for copyId"
)
);
expect(api.sessions.transcript).toHaveBeenCalledWith("s1", {
agent_id: "a4118367b503adecc",
limit: 20,
});
expect(screen.getByTestId("chat-background-agent")).toHaveTextContent("↓ 54.3K tokens");
});
it("reads nothing without the session", () => {
render(<MessageList live loading={false} messages={main} />);
expect(screen.getByTestId("chat-background-agent")).toBeInTheDocument();
expect(api.sessions.transcript).not.toHaveBeenCalled();
});
});
@@ -5,12 +5,13 @@
* layout), Main agent / System under their own label — instead of treating
* every `type:"user"` line as the user (reported transcript mis-attribution) —
* and folds a run of tool calls into one line; a typed `/command` or `@path`
* shows as a chip.
* shows as a chip. A live chat lists the background subagents still running
* and does not call their turn done (issue #81).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MessageList, turnStats, workLabel, workingOn } from "../MessageList";
import { MessageList, backgroundAgents, turnStats, workLabel, workingOn } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
@@ -343,4 +344,77 @@ describe("MessageList — sender attribution", () => {
// Silent for 15 minutes with nothing running: the turn died.
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
});
describe("background subagents", () => {
const at = new Date().toISOString();
const launch = msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "tool_use",
content: [
{
type: "tool_use",
id: "t1",
name: "Agent",
input: { description: "Checking copyId coverage", subagent_type: "Explore" },
},
],
});
const launched = msg({
timestamp: at,
content: [
{
type: "tool_result",
id: "t1",
output: JSON.stringify([{ type: "text", text: "Async agent launched successfully." }]),
},
],
});
const reply = msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "end_turn",
content: [{ type: "text", text: "waiting for the agent" }],
});
const notified = msg({
sender: "system",
timestamp: at,
content: [
{
type: "text",
text: "<task-notification>\n<task-id>a1</task-id>\n<tool-use-id>t1</tool-use-id>\n<status>completed</status>\n</task-notification>",
},
],
});
const base = [msg({ sender: "user", timestamp: at }), launch, launched, reply];
it("lists an agent until its notification names the call", () => {
const results = (m: TranscriptMessage[]) =>
new Map(
m.flatMap((x) => x.content.filter((c) => c.type === "tool_result").map((c) => [c.id!, c]))
);
expect(backgroundAgents(base, results(base)).map((a) => a.block.id)).toEqual(["t1"]);
const all = [...base, notified];
expect(backgroundAgents(all, results(all))).toEqual([]);
});
it("does not call the turn done while the agent works", () => {
const { rerender } = render(<MessageList live loading={false} messages={base} />);
expect(screen.getByTestId("chat-background-agent")).toHaveTextContent(
"ExploreChecking copyId coverage"
);
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Replied in");
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("1 agent still running");
rerender(<MessageList live loading={false} messages={[...base, notified]} />);
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in");
});
it("shows nothing once the session is no longer live", () => {
render(<MessageList loading={false} messages={base} />);
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
});
});
});
+30 -1
View File
@@ -9,7 +9,8 @@
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
* Settings → Git). There is no AI tool picker: the lane is created with the
* Settings → Git; **Partial clone** makes it blobless, and `CloneProgress`
* shows its queue place, then git's progress). There is no AI tool picker: the lane is created with the
* default AI tool (Settings → AI Tools), and each new session picks its own
* AI on the workspace home. Either
* mode's path field can be filled by typing, by the CwdAutocomplete
@@ -30,6 +31,7 @@ import { useDialog } from "../../hooks/useDialog";
import { CwdAutocomplete } from "../run/RunSetup";
import { PathPickButton } from "../PathPickButton";
import { api } from "../../lib/api";
import { CloneProgress, newCloneToken } from "./CloneProgress";
import type { CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
@@ -116,6 +118,9 @@ export function AddLaneModal({
// server asked for one — a login for the URL's https host.
const [cloneUrl, setCloneUrl] = useState("");
const [cloneName, setCloneName] = useState("");
const [clonePartial, setClonePartial] = useState(false);
/** The running clone's progress token (CloneProgress polls it). */
const [cloneToken, setCloneToken] = useState<string | null>(null);
const [login, setLogin] = useState<{
host: string;
username: string;
@@ -159,6 +164,7 @@ export function AddLaneModal({
setMode("repo");
setCloneUrl("");
setCloneName("");
setClonePartial(false);
setLogin(null);
setSshRefused(false);
setSourceRepo("");
@@ -236,13 +242,17 @@ export function AddLaneModal({
const url = cloneUrl.trim();
if (!url) return;
setSshRefused(false);
const progressToken = newCloneToken();
setCloneToken(progressToken);
try {
const result = await api.lanes.clone({
progressToken,
url,
parent: repo,
name: cloneName.trim() || undefined,
title: name || undefined,
tool,
...(clonePartial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
@@ -274,6 +284,8 @@ export function AddLaneModal({
setError(err instanceof Error ? err.message : String(err));
}
setBusy(false);
} finally {
setCloneToken(null);
}
return;
}
@@ -547,6 +559,23 @@ export function AddLaneModal({
spellCheck={false}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<label
className="mt-2 flex items-center gap-1.5 text-[11px] text-fg-secondary"
title={t("clone.partialHint")}
>
<input
type="checkbox"
data-testid="add-lane-clone-partial"
checked={clonePartial}
onChange={(e) => setClonePartial(e.target.checked)}
/>
{t("clone.partial")}
</label>
{cloneToken && (
<div className="mt-2">
<CloneProgress token={cloneToken} />
</div>
)}
</div>
)}
@@ -0,0 +1,93 @@
/**
* @file CloneProgress — the live line under a running clone: its place in the
* server's clone queue while it waits (clones run a few at a time machine-wide,
* `server/lib/clone-queue.js`), then git's own phase, percent, size and speed
* with a bar. It polls `GET /api/lanes/clone/progress/:token` every second for
* the token the clone's POST carried (`newCloneToken`); a 404 — the POST not
* yet arrived, or an older server — just shows nothing yet.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type CloneProgressView } from "../../lib/api";
const POLL_MS = 1000;
/** A fresh, unguessable progress token (works on plain http, unlike randomUUID). */
export function newCloneToken(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
export function CloneProgress({ token }: { token: string }) {
const { t } = useTranslation("lanes");
const [view, setView] = useState<CloneProgressView | null>(null);
useEffect(() => {
let alive = true;
setView(null);
// Never throws: progress is a courtesy, the clone's own answer decides.
const poll = () =>
Promise.resolve()
.then(() => api.lanes.cloneProgress(token))
.then((v) => {
if (alive) setView(v);
})
.catch(() => {});
void poll();
const timer = setInterval(() => void poll(), POLL_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [token]);
if (!view || view.state === "done" || view.state === "failed") return null;
if (view.state === "queued") {
return (
<p data-testid="clone-progress" className="text-[11px] text-status-warning">
{t("clone.queued", {
position: view.position ?? 1,
waiting: view.waiting,
running: view.running,
limit: view.limit,
})}
</p>
);
}
const phase = view.phase
? view.phase === "other"
? view.label || ""
: t(`clone.phase.${view.phase}`)
: t("clone.connecting");
const percent = view.percent ?? 0;
return (
<div data-testid="clone-progress" className="space-y-1">
<p className="flex justify-between gap-2 text-[11px] text-fg-secondary">
<span className="truncate">
{phase}
{view.phase ? ` ${percent}%` : ""}
</span>
{view.detail && (
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{view.detail}</span>
)}
</p>
{view.phase && (
<div
className="h-1 overflow-hidden rounded bg-surface-2"
role="progressbar"
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
aria-label={phase}
>
<div className="h-full bg-accent-solid transition-all" style={{ width: `${percent}%` }} />
</div>
)}
</div>
);
}
@@ -10,7 +10,8 @@
* sent to Settings → Git first — and an HTTPS host with no saved login offers
* the login row up front (optional: a public repo needs none). The identity is
* read again when the window regains focus, so a key made in another tab
* counts at once.
* counts at once. The running clone shows its queue place, then git's
* progress (`CloneProgress`); **Partial clone** sends `partial: true`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -18,6 +19,7 @@ import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Loader2, X } from "lucide-react";
import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api";
import { CloneProgress, newCloneToken } from "./CloneProgress";
type Row = {
url: string;
@@ -25,6 +27,8 @@ type Row = {
note?: string;
/** The note points at Settings → Git (no key, or a key the host refused). */
ssh?: boolean;
/** While busy: the progress token CloneProgress polls. */
token?: string;
};
type Login = { host: string; username: string; token: string; remember: boolean };
@@ -71,6 +75,7 @@ export function CloneSourcesForm({
const [rows, setRows] = useState<Row[]>([]);
const [busy, setBusy] = useState(false);
const [login, setLogin] = useState<Login | null>(null);
const [partial, setPartial] = useState(false);
const [identity, setIdentity] = useState<GitIdentity | null>(null);
// An unknown identity (an older server, a failed read) checks nothing: the
@@ -116,10 +121,13 @@ export function CloneSourcesForm({
for (const row of next) {
if (row.state === "failed") continue;
row.state = "busy";
row.token = newCloneToken();
show();
try {
const view = await api.lanes.cloneFolder(laneId, {
url: row.url,
progressToken: row.token,
...(partial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
@@ -151,6 +159,7 @@ export function CloneSourcesForm({
row.note = e.message || String(err);
}
}
row.token = undefined;
show();
}
if (asked) setLogin(asked);
@@ -191,6 +200,18 @@ export function CloneSourcesForm({
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
<label
className="flex items-center gap-1.5 text-[11px] text-fg-secondary"
title={t("clone.partialHint")}
>
<input
type="checkbox"
data-testid="workspace-clone-partial"
checked={partial}
onChange={(e) => setPartial(e.target.checked)}
/>
{t("clone.partial")}
</label>
{sshHosts.length > 0 && (
<p
role="alert"
@@ -280,6 +301,11 @@ export function CloneSourcesForm({
{r.url}
</span>
</span>
{r.state === "busy" && r.token && (
<div className="ml-[18px] mt-0.5">
<CloneProgress token={r.token} />
</div>
)}
{r.note && (
<span className="ml-[18px] block text-status-danger">
{r.note}
@@ -1728,7 +1728,12 @@ export function LaneChangesPanel({
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
<MenuItem
danger
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
// Pushed: the server refuses it (EPUSHED), so say why here
// instead of failing after the confirm.
disabled={
busy || opOpen || headPushed || commitCtx.menu.item.parents.length !== 1
}
title={headPushed ? t("changes.undoCommitPushed") : undefined}
testId="lane-changes-menu-undo-commit"
onSelect={pick(
commitCtx.close,
@@ -94,7 +94,7 @@ export function SessionReplay({
const tool = session?.tool || "claude";
const resumable = byId(tool)?.resumeById ?? RESUMABLE.has(tool);
const livePane = liveRunFor(lane, liveRuns, sessionId);
const text = promptWithImages(prompt, images);
const text = promptWithImages(prompt, images, tool === "claude");
const send = async () => {
if (!text || busy || uploading || livePane || !resumable) return;
+28 -16
View File
@@ -11,7 +11,7 @@
* shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
* a catalog tool's bypass) 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,
* **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,
@@ -20,7 +20,6 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Checkbox } from "../Checkbox";
import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm";
import { useEffect, useRef, useState } from "react";
@@ -34,6 +33,7 @@ import {
MoreHorizontal,
ArrowUp,
SquareTerminal,
Zap,
} from "lucide-react";
import {
api,
@@ -76,7 +76,7 @@ export {
} from "./startSession";
import { startInFreeTab } from "./startSession";
import { WorkspaceContext } from "./WorkspaceContext";
import { readDefaultMode } from "../../lib/sessionDefaults";
import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
/** Recents' page size; Show more adds another. */
const RECENT_PAGE = 30;
@@ -165,12 +165,14 @@ function HomeBody({
// Claude and Codex always take a model and an effort; a catalog tool a
// model only when its file names the flag, an effort never.
const hasModel = claude || codex || !!entry?.modelFlag;
// The account's own default (first-run setup) — never bypass.
// The account's own defaults (first-run setup, Settings → AI tools →
// Session defaults); a pick here is for this session only.
const [mode, setMode] = useState<PermissionMode>(readDefaultMode);
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
const [approval, setApproval] = useState<CodexApproval>("on-request");
// Per session, never remembered.
const [sandbox, setSandbox] = useState<CodexSandbox>(() => readCodexDefaults().sandbox);
const [approval, setApproval] = useState<CodexApproval>(() => readCodexDefaults().approval);
// Off for every tool but Codex, which starts from the account's default.
const [bypass, setBypass] = useState(false);
useEffect(() => setBypass(codex && readCodexDefaults().bypass), [codex]);
const canBypass = !claude && !!entry?.bypass;
const [effort, setEffort] = useState<EffortLevel>("");
const hasEffort = claude || codex;
@@ -254,7 +256,7 @@ function HomeBody({
}
};
const text = promptWithImages(prompt, images);
const text = promptWithImages(prompt, images, claude);
const start = async () => {
// Enter reaches here past the disabled button: the same guards again.
// A ref, not only the state: two Enters in one tick both read `starting` false.
@@ -607,15 +609,25 @@ function HomeBody({
</div>
)}
{canBypass && (
<span className="px-2">
<Checkbox
tone="danger"
checked={bypass}
onChange={setBypass}
label={t("run:fields.bypass")}
labelClassName="text-xs text-status-danger"
// A chip like the selects beside it: quiet when off, red when on.
<button
type="button"
aria-pressed={bypass}
data-testid="workspace-home-bypass"
title={t(codex ? "run:fields.yoloHint" : "run:fields.bypass")}
onClick={() => setBypass(!bypass)}
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-status-danger/60 ${
bypass
? "bg-status-danger/10 font-medium text-status-danger ring-1 ring-inset ring-status-danger/30 hover:bg-status-danger/20"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<Zap
className={`h-3.5 w-3.5 ${bypass ? "fill-current" : "text-fg-muted"}`}
aria-hidden
/>
</span>
{t(codex ? "run:fields.yolo" : "run:fields.bypassShort")}
</button>
)}
</div>
{danger && (
@@ -786,9 +786,25 @@ describe("AddLaneModal — Clone", () => {
)
);
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].credentials).toBeUndefined();
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].partial).toBeUndefined();
await waitFor(() => expect(onAdded).toHaveBeenCalled());
});
it("sends partial only when the box is ticked", async () => {
vi.mocked(api.lanes.clone).mockResolvedValue({
lane: laneFixture({ id: 7 }),
created: true,
path: "/home/an/code/big",
});
const user = userEvent.setup();
await fillClone(user, "https://git.example.com/o/big.git");
await user.click(screen.getByTestId("add-lane-clone-partial"));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ partial: true }))
);
});
it("asks for a login on ECREDENTIALS and sends it on the retry", async () => {
vi.mocked(api.lanes.clone)
.mockRejectedValueOnce(
@@ -0,0 +1,79 @@
/**
* @file Tests for CloneProgress: a queued clone shows its place in the
* server's clone queue, a running one git's phase, percent and speed with a
* bar, and nothing shows before the server knows the token. Also that
* `newCloneToken` makes a token the server accepts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import "../../../i18n";
vi.mock("../../../lib/api", () => ({
api: { lanes: { cloneProgress: vi.fn() } },
}));
import { api, type CloneProgressView } from "../../../lib/api";
import { CloneProgress, newCloneToken } from "../CloneProgress";
const base: CloneProgressView = {
state: "running",
position: null,
running: 2,
waiting: 1,
limit: 2,
phase: null,
label: null,
percent: null,
detail: null,
};
describe("CloneProgress", () => {
// Braces: a function returned from beforeEach is run as its teardown.
beforeEach(() => {
vi.mocked(api.lanes.cloneProgress).mockReset();
});
it("shows the clone's place in the queue while it waits", async () => {
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
...base,
state: "queued",
position: 1,
});
render(<CloneProgress token="t" />);
expect(await screen.findByTestId("clone-progress")).toHaveTextContent(
"Waiting in the clone queue — #1 of 1 (2/2 clones running)"
);
});
it("shows git's phase, percent and speed with a bar while it runs", async () => {
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
...base,
phase: "receiving",
label: "Receiving objects",
percent: 45,
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
});
render(<CloneProgress token="t" />);
const line = await screen.findByTestId("clone-progress");
expect(line).toHaveTextContent("Downloading 45%");
expect(line).toHaveTextContent("120.50 MiB | 5.20 MiB/s");
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
});
it("shows nothing while the server does not know the token yet", async () => {
vi.mocked(api.lanes.cloneProgress).mockImplementation(() =>
Promise.reject(Object.assign(new Error("no such clone"), { code: "ENOTFOUND" }))
);
render(<CloneProgress token="t" />);
await new Promise((r) => setTimeout(r, 20));
expect(screen.queryByTestId("clone-progress")).toBeNull();
});
it("makes tokens the server accepts", () => {
const a = newCloneToken();
expect(a).toMatch(/^[A-Za-z0-9_-]{16,64}$/);
expect(newCloneToken()).not.toBe(a);
});
});
@@ -3,7 +3,8 @@
* an extra folder, a host that wants a login opens the login row, and Retry
* re-runs only the failed URLs with that login. An SSH link while the
* account has no key is never sent — the user is pointed at Settings → Git —
* and an HTTPS host with no saved login is flagged before Clone.
* and an HTTPS host with no saved login is flagged before Clone. The
* partial-clone box adds `partial: true` to every clone.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -59,12 +60,29 @@ describe("CloneSourcesForm", () => {
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
progressToken: expect.any(String),
url: "https://private.test/b.git",
credentials: { username: "dev", password: "tok", remember: true },
});
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
});
it("asks for partial clones when the box is ticked", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
await user.type(screen.getByTestId("workspace-clone-input"), "https://h/big.git");
await user.click(screen.getByTestId("workspace-clone-partial"));
await user.click(screen.getByTestId("workspace-clone-run"));
await waitFor(() =>
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
progressToken: expect.any(String),
url: "https://h/big.git",
partial: true,
})
);
});
it("tells an SSH link from an HTTPS one", () => {
expect(cloneTarget("git@github.com:o/r.git")).toEqual({ kind: "ssh", host: "github.com" });
expect(cloneTarget("ssh://git@h.test:2222/o/r")).toEqual({ kind: "ssh", host: "h.test" });
@@ -92,7 +110,10 @@ describe("CloneSourcesForm", () => {
await user.click(screen.getByTestId("workspace-clone-run"));
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(1));
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, { url: "https://h.test/a.git" });
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
url: "https://h.test/a.git",
progressToken: expect.any(String),
});
expect(await screen.findByText(/has no SSH key yet/)).toBeInTheDocument();
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git");
});
@@ -1062,6 +1062,20 @@ describe("LaneChangesPanel", () => {
);
});
it("does not offer Undo for a top commit its upstream already holds", async () => {
withParent();
vi.mocked(api.lanes.git).mockResolvedValue(
facts({ hasRemote: true, upstream: "origin/feat/thing", ahead: 0, behind: 0 })
);
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "History" }));
const undo = within(await commitMenu(user)).getByTestId("lane-changes-menu-undo-commit");
await waitFor(() => expect(undo).toBeDisabled());
expect(undo).toHaveAttribute("title", expect.stringMatching(/already on the remote/));
});
it("cherry-picks only from another branch's history", async () => {
withParent();
vi.mocked(api.lanes.git).mockResolvedValue(facts());
@@ -245,7 +245,9 @@ describe("WorkspaceHome", () => {
// As the Workspace page: `&run=` replaces the home with the session view.
function HomeUntilRun() {
const [params] = useSearchParams();
return params.get("run") ? null : <WorkspaceHome lane={lane()} onMenuAction={() => undefined} />;
return params.get("run") ? null : (
<WorkspaceHome lane={lane()} onMenuAction={() => undefined} />
);
}
const user = userEvent.setup();
render(
@@ -29,9 +29,9 @@ import { PetSettings } from "../settings/PetSettings";
import { Swatch, THEME_LABELS } from "../settings/AppearanceSettings";
import { markOnboarded, useOnboarding } from "../../lib/userPrefs";
import {
DEFAULT_MODES,
readDefaultMode,
readDefaultTool,
SAFE_MODES,
writeDefaultMode,
writeDefaultTool,
type DefaultMode,
@@ -310,7 +310,7 @@ function AgentStep() {
writeDefaultMode(m);
setMode(m);
}}
options={DEFAULT_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))}
options={SAFE_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))}
/>
</div>
<p className="text-xs text-fg-muted">{t("agent.permissionHint")}</p>
@@ -2,7 +2,7 @@
* @file Onboarding.test.tsx
* @description The first-run setup (docs/plans/onboarding): the account's
* preferences are loaded into localStorage on sign-in and every later change
* is sent back; the starting choices never hold bypass; the wizard shows only
* is sent back; the wizard never offers bypass (a Settings default may hold it); the wizard shows only
* for an account that is new to it, writes each pick like Settings does and
* ends in the tour; the tour follows the real UI step by step.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -55,7 +55,7 @@ vi.mock("../../settings/PetSettings", () => ({
}));
import { __resetUserPrefs, hydrate, useOnboarding } from "../../../lib/userPrefs";
import { readDefaultMode, writeDefaultMode } from "../../../lib/sessionDefaults";
import { readDefaultMode, SAFE_MODES, writeDefaultMode } from "../../../lib/sessionDefaults";
import { OnboardingWizard } from "../OnboardingWizard";
import { OnboardingTour, startTour } from "../OnboardingTour";
import { __resetAiTools } from "../../../hooks/useAiTools";
@@ -98,10 +98,12 @@ describe("account preferences", () => {
}
});
it("never keeps bypass as the starting permission mode", () => {
it("keeps a bypass default from Settings, but the pet chat's safe read and the wizard never offer it", () => {
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
expect(readDefaultMode()).toBe("auto");
writeDefaultMode("bypassPermissions" as never);
expect(readDefaultMode()).toBe("bypassPermissions");
expect(readDefaultMode({ safe: true })).toBe("auto");
expect(SAFE_MODES).not.toContain("bypassPermissions");
writeDefaultMode("nonsense" as never);
expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions");
writeDefaultMode("plan");
expect(readDefaultMode()).toBe("plan");
@@ -218,6 +218,7 @@ export function LaneConsolePane({
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
autoFocus={autoFocus && !(chatable && chat)}
covered={!!(chatable && chat)}
tool={handle.tool}
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
// the tab bar moves on (another tab, or the home's prompt box)
// rather than leaving a dead terminal the user has to click out of.
+5 -2
View File
@@ -476,7 +476,10 @@ export const PaneChat = memo(function PaneChat({
// Stable, so typing in the prompt box does not re-render the conversation
// (ConversationView is memoized).
const openTerminal = useCallback(() => showTerminal(runId, sessionId), [runId, sessionId]);
const openTerminal = useCallback(
() => showTerminal(runId, sessionId, run.tool),
[runId, sessionId, run.tool]
);
const openFile = useCallback(
(named: string) => {
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
@@ -514,7 +517,7 @@ export const PaneChat = memo(function PaneChat({
const send = async () => {
if (!text || uploading) return;
const out = promptForPane(prompt, images);
const out = promptForPane(prompt, images, claude);
if (!(await command(out.text, out.files))) return;
setFollow((n) => n + 1);
setPrompt("");
+3 -3
View File
@@ -244,9 +244,9 @@ const ITEM =
* the draft (its files as paths) back into the input box, never sent. */
/** A session's chat switched to its terminal: the unsent prompt goes into
* the CLI's input box, without Enter. */
export function showTerminal(runId: string, sessionId: string) {
export function showTerminal(runId: string, sessionId: string, tool?: string) {
const draft = readChatDraft(sessionId);
const { text, files } = promptForPane(draft.prompt, draft.images);
const { text, files } = promptForPane(draft.prompt, draft.images, tool === "claude");
if ((text || files.length) && pasteToRun(runId, text, files)) writeChatDraft(sessionId, "", []);
setSessionChat(sessionId, false);
}
@@ -265,7 +265,7 @@ function ChatToggle({ run }: { run?: RunHandle }) {
title={label}
aria-label={label}
onClick={() => {
if (chat) return showTerminal(run.id, sessionId);
if (chat) return showTerminal(run.id, sessionId, run.tool);
const draft = readChatDraft(sessionId);
const text = takeRunInput(run.id);
if (text)
+17 -8
View File
@@ -67,6 +67,7 @@ import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens";
import {
ATTACH_MIME,
addAttachments,
claudeMention,
droppedAttachments,
droppedSkill,
droppedText,
@@ -203,21 +204,29 @@ interface PromptEditorProps {
export type PromptImage = { path: string; url?: string; name: string };
/** The prompt as sent: the text, then each file's saved path — which is how
* the CLI is handed an image or a file. */
export function promptWithImages(text: string, images: PromptImage[]): string {
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
* the CLI is handed an image or a file; for `claude`, as `@` mentions. */
export function promptWithImages(text: string, images: PromptImage[], claude = false): string {
return [text.trim(), ...images.map((i) => (claude ? claudeMention(i.path) : i.path))]
.filter(Boolean)
.join(" ");
}
const IMAGE_PATH = /^[^@].*\.(png|jpe?g|gif|webp)$/i;
/** The prompt as pasted into a live pane: the text with every non-image file
* in it (as `promptWithImages`), and the images' paths apart — each goes in
* a paste of its own, the one shape `claude` turns into `[Image #n]` on every
* platform (a Windows path inside a paste of text stayed a plain path). */
/** The prompt as pasted into a live pane. `claude`: one paste, every file an
* `@` mention, and a trailing space so a mention at the end does not leave
* the `@` file suggester open to swallow the Enter after it. Any other tool:
* the text with every non-image file in it, and the images' paths apart,
* each in a paste of its own (how `codex` attaches a pasted image path). */
export function promptForPane(
text: string,
images: PromptImage[]
images: PromptImage[],
claude = false
): { text: string; files: string[] } {
if (claude) {
const all = promptWithImages(text, images, true);
return { text: images.length ? `${all} ` : all, files: [] };
}
const isImage = (i: PromptImage) => IMAGE_PATH.test(i.path);
return {
text: promptWithImages(text, images.filter((i) => !isImage(i))),
+9 -3
View File
@@ -16,8 +16,8 @@
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
* local terminal does on a drop, and how `claude` attaches an image or reads
* a file. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
* reads them (an image as an image) when the prompt is sent. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
* pasted into whichever of the workspace's panes was focused last.
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
@@ -63,6 +63,7 @@ import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
import { writeClipboard } from "../../lib/clipboard";
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
import { claudeMention } from "../../lib/promptAttach";
import { attachImeInput } from "../../lib/imeInput";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
@@ -160,6 +161,9 @@ interface TerminalViewProps {
* so it is moved out of the viewport with a transform, which keeps its
* layout size (no PTY resize; the chat still reads and answers its menus). */
covered?: boolean;
/** The pane's AI tool: a `claude` pane is handed a pasted or dropped file
* as an `@` mention (`claudeMention`), any other one its plain path. */
tool?: string;
}
/** Memoized: the Workspace re-renders its pane tree on every lane_update, and
@@ -170,6 +174,7 @@ export const TerminalView = memo(function TerminalView({
onExit,
autoFocus = true,
covered = false,
tool,
}: TerminalViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
// Read through a ref: an inline `onExit` from the parent changes identity on
@@ -261,7 +266,8 @@ export const TerminalView = memo(function TerminalView({
const saved = await Promise.all(files.map((f) => api.run.dropFile(f, runId)));
// paste(), not a raw send: it honours bracketed-paste mode, so claude
// sees pasted paths rather than typed keystrokes.
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
const asText = tool === "claude" ? claudeMention : (p: string) => p;
termRef.current?.paste(saved.map((r) => asText(r.path)).join(" ") + " ");
termRef.current?.focus();
setDrop(null);
} catch {
@@ -430,7 +430,7 @@ describe("PaneChat — the unsent prompt", () => {
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
expect(screen.getByText("a.ts")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts", []);
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts ", []);
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
});
@@ -462,7 +462,9 @@ describe("PaneChat — the unsent prompt", () => {
fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith(
"agenthub-lane-1",
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts',
// A trailing space: an @ mention at the end would leave claude's file
// suggester open, and it takes the Enter.
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts ',
[]
);
});
@@ -606,4 +606,26 @@ describe("promptForPane — images apart, every other file in the text", () => {
],
});
});
it("hands claude every file as an @ mention in one paste, with a trailing space", () => {
// A bare pasted path stayed plain text on Windows, and a path in the
// command-line prompt is never an image; claude reads an @ mention at submit.
const images: PromptImage[] = [
{ path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", name: "image.png" },
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
{ path: "C:\\Users\\Jo Do\\drops\\0123456789abcdef.png", name: "b.png" },
];
expect(promptForPane(" look ", images, true)).toEqual({
text:
'look @C:/Users/u/.claude/agenthub/drops/f326fc666d3bdd92-image.png ' +
'@/data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG" ' +
'@"C:/Users/Jo Do/drops/0123456789abcdef.png" ',
files: [],
});
expect(promptForPane("just text", [], true)).toEqual({ text: "just text", files: [] });
expect(promptWithImages("see", [images[1]!], true)).toBe(
"see @/data/drops/0123456789abcdef-log.txt"
);
});
});
@@ -0,0 +1,154 @@
/**
* @file SessionDefaultsSettings — Settings → AI tools → Session defaults: the
* account's own starting choices for a new Claude Code session (permission
* mode, Bypass included) and a new Codex session (sandbox, approval, bypass),
* which the workspace home's prompt box starts from (`lib/sessionDefaults.ts`,
* synced per account). Turning bypass on as a default asks first; a pick in
* the prompt box still changes only that one session.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ShieldAlert } from "lucide-react";
import type { CodexApproval, CodexSandbox } from "../../lib/api";
import { useAiTools } from "../../hooks/useAiTools";
import { confirmDialog } from "../../lib/confirm";
import {
CODEX_APPROVALS,
CODEX_SANDBOXES,
DEFAULT_MODES,
readCodexDefaults,
readDefaultMode,
writeCodexDefaults,
writeDefaultMode,
type DefaultMode,
} from "../../lib/sessionDefaults";
import { SettingsRow, SettingsSection, SettingsSegmented, SettingsSwitchRow } from "./SettingsRow";
const MODE_KEYS: Record<DefaultMode, string> = {
auto: "Auto",
acceptEdits: "AcceptEdits",
default: "Default",
plan: "Plan",
bypassPermissions: "Bypass",
};
const SANDBOX_KEYS: Record<CodexSandbox, string> = {
"read-only": "sandboxReadOnly",
"workspace-write": "sandboxWorkspaceWrite",
"danger-full-access": "sandboxFullAccess",
};
const APPROVAL_KEYS: Record<CodexApproval, string> = {
"on-request": "approvalOnRequest",
never: "approvalNever",
};
function Danger({ children }: { children: string }) {
return (
<p className="flex items-start gap-2 py-2 text-xs text-status-danger">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{children}
</p>
);
}
export function SessionDefaultsSettings() {
const { t } = useTranslation("settings");
const aiTools = useAiTools();
const shown = (id: string) => aiTools.tools.some((x) => x.id === id && x.enabled);
const [mode, setMode] = useState<DefaultMode>(() => readDefaultMode() as DefaultMode);
const [codex, setCodex] = useState(readCodexDefaults);
const askBypass = (tool: string) =>
confirmDialog({
title:
tool === "Codex"
? t("sessionDefaults.codexConfirmTitle")
: t("sessionDefaults.confirmTitle", { tool }),
message: t("sessionDefaults.confirmBody", { tool }),
confirmLabel: t("sessionDefaults.confirmLabel"),
});
const pickMode = async (m: DefaultMode) => {
if (m === "bypassPermissions" && !(await askBypass("Claude Code"))) return;
writeDefaultMode(m);
setMode(m);
};
const patchCodex = (patch: Partial<typeof codex>) => {
writeCodexDefaults(patch);
setCodex((c) => ({ ...c, ...patch }));
};
const pickCodexBypass = async (on: boolean) => {
if (on && !(await askBypass("Codex"))) return;
patchCodex({ bypass: on });
};
if (!shown("claude") && !shown("codex")) return null;
return (
<SettingsSection
id="session-defaults"
title={t("sessionDefaults.title")}
description={t("sessionDefaults.description")}
>
{shown("claude") && (
<>
<SettingsRow
label={t("sessionDefaults.claudeMode")}
description={t("sessionDefaults.claudeModeDesc")}
control={
<SettingsSegmented
ariaLabel={t("sessionDefaults.claudeMode")}
value={mode}
onChange={(m) => void pickMode(m)}
options={DEFAULT_MODES.map((m) => ({
value: m,
label: t(`lanes:home.permission${MODE_KEYS[m]}`),
}))}
/>
}
/>
{mode === "bypassPermissions" && <Danger>{t("run:hint.permissionWarning")}</Danger>}
</>
)}
{shown("codex") && (
<>
<SettingsRow
label={t("sessionDefaults.codexSandbox")}
control={
<SettingsSegmented
ariaLabel={t("sessionDefaults.codexSandbox")}
value={codex.sandbox}
onChange={(sandbox) => patchCodex({ sandbox })}
options={CODEX_SANDBOXES.map((v) => ({
value: v,
label: t(`run:fields.${SANDBOX_KEYS[v]}`),
}))}
/>
}
/>
<SettingsRow
label={t("sessionDefaults.codexApproval")}
control={
<SettingsSegmented
ariaLabel={t("sessionDefaults.codexApproval")}
value={codex.approval}
onChange={(approval) => patchCodex({ approval })}
options={CODEX_APPROVALS.map((v) => ({
value: v,
label: t(`run:fields.${APPROVAL_KEYS[v]}`),
}))}
/>
}
/>
<SettingsSwitchRow
label={t("sessionDefaults.codexBypass")}
description={t("sessionDefaults.codexBypassDesc")}
checked={codex.bypass}
onChange={(on) => void pickCodexBypass(on)}
/>
{codex.bypass && <Danger>{t("run:hint.bypassWarning", { tool: "Codex" })}</Danger>}
</>
)}
</SettingsSection>
);
}
@@ -0,0 +1,72 @@
/**
* @file SessionDefaultsSettings.test.tsx
* @description Session defaults: Bypass becomes Claude's default permission
* mode or Codex's default bypass only after the confirm, a cancelled confirm
* keeps the old default, the plain modes save at once, and the pet chat's
* safe read never returns bypass.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SessionDefaultsSettings } from "../SessionDefaultsSettings";
import { confirmDialog } from "../../../lib/confirm";
import { readCodexDefaults, readDefaultMode } from "../../../lib/sessionDefaults";
const aiTools = vi.hoisted(() => ({
tools: [
{ id: "claude", label: "Claude Code", installed: true, enabled: true },
{ id: "codex", label: "Codex", installed: true, enabled: true },
],
}));
vi.mock("../../../hooks/useAiTools", () => ({ useAiTools: () => aiTools }));
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn() }));
const confirm = vi.mocked(confirmDialog);
const radio = (name: RegExp) => screen.getAllByRole("radio", { name })[0]!;
describe("SessionDefaultsSettings", () => {
beforeEach(() => {
localStorage.clear();
confirm.mockReset();
});
it("saves a plain mode without asking", () => {
render(<SessionDefaultsSettings />);
fireEvent.click(radio(/plan/i));
expect(confirm).not.toHaveBeenCalled();
expect(readDefaultMode()).toBe("plan");
});
it("makes Bypass Claude's default only once confirmed", async () => {
confirm.mockResolvedValueOnce(false);
render(<SessionDefaultsSettings />);
fireEvent.click(radio(/bypass/i));
await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1));
expect(readDefaultMode()).toBe("auto");
confirm.mockResolvedValueOnce(true);
fireEvent.click(radio(/bypass/i));
await waitFor(() => expect(readDefaultMode()).toBe("bypassPermissions"));
expect(readDefaultMode({ safe: true })).toBe("auto");
});
it("turns Codex's default bypass on only once confirmed, off without asking", async () => {
confirm.mockResolvedValueOnce(true);
render(<SessionDefaultsSettings />);
const sw = screen.getByRole("switch");
fireEvent.click(sw);
await waitFor(() => expect(readCodexDefaults().bypass).toBe(true));
fireEvent.click(screen.getByRole("switch"));
await waitFor(() => expect(readCodexDefaults().bypass).toBe(false));
expect(confirm).toHaveBeenCalledTimes(1);
});
it("shows nothing when neither Claude nor Codex is enabled", () => {
const saved = aiTools.tools;
aiTools.tools = [];
const { container } = render(<SessionDefaultsSettings />);
expect(container.firstChild).toBeNull();
aiTools.tools = saved;
});
});
+9
View File
@@ -220,6 +220,14 @@
"clone.remember": "Remember this login for my account",
"clone.sshRefused": "{{host}} refused the SSH login. Add your public key to that host, or make one in Settings → Git.",
"clone.sshSettings": "Open Settings → Git",
"clone.partial": "Partial clone (faster for big repos)",
"clone.partialHint": "Downloads every commit but a file's contents only when it is checked out (--filter=blob:none). Checking out an old revision later fetches from the remote.",
"clone.queued": "Waiting in the clone queue — #{{position}} of {{waiting}} ({{running}}/{{limit}} clones running)",
"clone.connecting": "Connecting…",
"clone.phase.remote": "Server preparing",
"clone.phase.receiving": "Downloading",
"clone.phase.resolving": "Unpacking",
"clone.phase.checkout": "Writing files",
"clone.sshNoKey": "Not cloned: your account has no SSH key yet. Make one in Settings → Git, add its public key to {{host}}, then retry.",
"moreActions": "More actions",
"pipelinePicker.label": "Pipeline template",
@@ -299,6 +307,7 @@
"changes.undoCommitTitle": "Undo the last commit?",
"changes.undoCommitBody": "{{short}} \"{{subject}}\" is removed from the branch. Its changes stay staged, so you can edit them and commit again.",
"changes.undoCommitConfirm": "Undo commit",
"changes.undoCommitPushed": "The last commit is already on the remote branch (as of the last fetch); undoing it would rewrite shared history. Revert it instead — or Fetch first if the remote no longer has it.",
"changes.revertConfirm": "Add a commit on {{branch}} that undoes {{short}} \"{{subject}}\"? Files in this workspace change now.",
"changes.cherryPickConfirm": "Apply {{short}} \"{{subject}}\" from {{from}} onto {{branch}} as a new commit? Files in this workspace change now.",
"changes.amendTitle": "Amend the last commit?",
+3
View File
@@ -50,6 +50,9 @@
"permissionAuto": "auto (recommended - asks only for risky actions)",
"permissionBypass": "bypassPermissions (DANGER - allows everything without asking)",
"bypass": "Bypass permissions for this run",
"yolo": "YOLO",
"yoloHint": "YOLO mode for this session: no sandbox, no approvals (--dangerously-bypass-approvals-and-sandbox)",
"bypassShort": "Bypass",
"permissionDefault": "default (interactive prompts - sessions may hang headlessly)",
"permissionMode": "Permission mode",
"permissionPlan": "plan (read-only planning)",
+7 -1
View File
@@ -210,7 +210,13 @@
"moreSteps_other": "+{{count}} more",
"quiet": "no new activity for {{time}}",
"askDeclined": "Declined — no answer given",
"turnDone": "Done in {{time}}"
"turnDone": "Done in {{time}}",
"turnReplied": "Replied in {{time}}",
"agentsStill_one": "{{count}} agent still running",
"agentsStill_other": "{{count}} agents still running",
"agentsRunning_one": "{{count}} agent running in the background",
"agentsRunning_other": "{{count}} agents running in the background",
"agentContext": "{{tokens}} tokens"
},
"overview": {
"agent": "Agent",
+14
View File
@@ -1307,6 +1307,20 @@
"labelDesc": "A Claude or Codex session first shows its conversation like Claude Desktop, with a message box, instead of its terminal. Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and menus still need the terminal.",
"saveFailed": "Could not save: {{message}}"
},
"sessionDefaults": {
"title": "Session defaults",
"description": "What a new session starts with in a workspace's prompt box. Saved for your account only; a pick in the prompt box changes only that one session.",
"claudeMode": "Claude Code permission mode",
"claudeModeDesc": "Bypass permissions lets every new Claude session run any tool without asking.",
"codexSandbox": "Codex sandbox",
"codexApproval": "Codex approval",
"codexBypass": "Codex: YOLO mode",
"codexBypassDesc": "Every new Codex session starts in YOLO mode — `--dangerously-bypass-approvals-and-sandbox`: no sandbox, no approvals.",
"confirmTitle": "Make bypass the default for {{tool}}?",
"confirmBody": "Every new {{tool}} session you start will run commands and edit files without asking, until you change this back. Use it only with folders and prompts you trust.",
"confirmLabel": "Make it the default",
"codexConfirmTitle": "Make YOLO the default for Codex?"
},
"agentInstructions": {
"title": "My agent instructions",
"description": "Your own rules in Markdown — coding style, language, workflow. Every new session in a workspace you own starts with them, before that workspace's own instructions. Saved for your account only; sessions already running keep what they started with.",
+9
View File
@@ -220,6 +220,14 @@
"clone.remember": "Lưu đăng nhập này cho tài khoản của tôi",
"clone.sshRefused": "{{host}} từ chối đăng nhập SSH. Hãy thêm public key của bạn vào host đó, hoặc tạo key trong Cài đặt → Git.",
"clone.sshSettings": "Mở Cài đặt → Git",
"clone.partial": "Clone một phần (nhanh hơn với repo lớn)",
"clone.partialHint": "Tải toàn bộ commit nhưng chỉ tải nội dung file khi cần checkout (--filter=blob:none). Checkout một phiên bản cũ sau này sẽ tải thêm từ remote.",
"clone.queued": "Đang chờ trong hàng clone — thứ {{position}}/{{waiting}} ({{running}}/{{limit}} clone đang chạy)",
"clone.connecting": "Đang kết nối…",
"clone.phase.remote": "Máy chủ đang chuẩn bị",
"clone.phase.receiving": "Đang tải về",
"clone.phase.resolving": "Đang giải nén",
"clone.phase.checkout": "Đang ghi file",
"clone.sshNoKey": "Chưa clone: tài khoản của bạn chưa có SSH key. Hãy tạo key trong Cài đặt → Git, thêm public key vào {{host}}, rồi thử lại.",
"moreActions": "Thêm hành động",
"pipelinePicker.label": "Mẫu pipeline",
@@ -299,6 +307,7 @@
"changes.undoCommitTitle": "Hoàn tác commit cuối?",
"changes.undoCommitBody": "{{short}} \"{{subject}}\" sẽ bị gỡ khỏi nhánh. Thay đổi của nó vẫn được giữ ở trạng thái staged để sửa và commit lại.",
"changes.undoCommitConfirm": "Hoàn tác commit",
"changes.undoCommitPushed": "Commit cuối đã có trên nhánh remote (theo lần fetch gần nhất); hoàn tác sẽ viết lại lịch sử đã chia sẻ. Hãy Revert — hoặc Fetch trước nếu remote không còn commit này.",
"changes.revertConfirm": "Thêm một commit trên {{branch}} để đảo ngược {{short}} \"{{subject}}\"? File trong workspace sẽ thay đổi ngay.",
"changes.cherryPickConfirm": "Áp {{short}} \"{{subject}}\" từ {{from}} vào {{branch}} thành một commit mới? File trong workspace sẽ thay đổi ngay.",
"changes.amendTitle": "Amend commit cuối?",
+3
View File
@@ -50,6 +50,9 @@
"permissionAuto": "auto (khuyên dùng - chỉ hỏi khi hành động rủi ro)",
"permissionBypass": "bypassPermissions (NGUY HIỂM - cho phép tất cả mà không hỏi)",
"bypass": "Bỏ qua xin quyền cho lần chạy này",
"yolo": "YOLO",
"yoloHint": "Chế độ YOLO cho phiên này: không sandbox, không hỏi phê duyệt (--dangerously-bypass-approvals-and-sandbox)",
"bypassShort": "Bỏ qua quyền",
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
"permissionMode": "Chế độ quyền",
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
+7 -1
View File
@@ -210,7 +210,13 @@
"moreSteps_other": "+{{count}} việc nữa",
"quiet": "không có hoạt động mới trong {{time}}",
"askDeclined": "Đã từ chối — không trả lời",
"turnDone": "Xong trong {{time}}"
"turnDone": "Xong trong {{time}}",
"turnReplied": "Đã trả lời trong {{time}}",
"agentsStill_one": "còn {{count}} agent đang chạy",
"agentsStill_other": "còn {{count}} agent đang chạy",
"agentsRunning_one": "{{count}} agent đang chạy nền",
"agentsRunning_other": "{{count}} agent đang chạy nền",
"agentContext": "{{tokens}} token"
},
"overview": {
"agent": "Agent",
+14
View File
@@ -1307,6 +1307,20 @@
"labelDesc": "Session Claude hoặc Codex sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal. Sau đó mỗi session giữ dạng xem riêng: nút chat/terminal cạnh nút split của pane để chuyển. Hỏi quyền và menu vẫn cần terminal.",
"saveFailed": "Không lưu được: {{message}}"
},
"sessionDefaults": {
"title": "Mặc định cho phiên mới",
"description": "Phiên mới bắt đầu với các lựa chọn này trong ô prompt của workspace. Chỉ lưu cho tài khoản của bạn; chọn khác trong ô prompt chỉ đổi cho phiên đó.",
"claudeMode": "Chế độ quyền của Claude Code",
"claudeModeDesc": "Bỏ qua quyền cho mọi phiên Claude mới chạy mọi tool mà không hỏi.",
"codexSandbox": "Sandbox của Codex",
"codexApproval": "Phê duyệt của Codex",
"codexBypass": "Codex: chế độ YOLO",
"codexBypassDesc": "Mọi phiên Codex mới chạy ở chế độ YOLO — `--dangerously-bypass-approvals-and-sandbox`: không sandbox, không hỏi phê duyệt.",
"confirmTitle": "Đặt bỏ qua quyền làm mặc định cho {{tool}}?",
"confirmBody": "Mọi phiên {{tool}} mới bạn mở sẽ chạy lệnh và sửa file mà không hỏi, cho tới khi bạn đổi lại. Chỉ dùng với thư mục và prompt bạn tin tưởng.",
"confirmLabel": "Đặt làm mặc định",
"codexConfirmTitle": "Đặt YOLO làm mặc định cho Codex?"
},
"agentInstructions": {
"title": "Hướng dẫn agent của tôi",
"description": "Quy tắc riêng của bạn bằng Markdown — style code, ngôn ngữ, quy trình. Mọi session mới trong workspace của bạn đều nhận chúng, trước hướng dẫn riêng của workspace. Chỉ lưu cho tài khoản của bạn; session đang chạy giữ nội dung lúc khởi động.",
+31
View File
@@ -3576,11 +3576,22 @@ export const api = {
title?: string;
tool?: LaneTool;
credentials?: { username: string; password: string; remember?: boolean };
/** Blobless clone (`--filter=blob:none`): file contents fetched on checkout. */
partial?: boolean;
/** This clone's name for {@link lanes.cloneProgress}. */
progressToken?: string;
}) =>
request<{ lane: Lane; created: boolean; path: string }>("/lanes/clone", {
method: "POST",
body: JSON.stringify(body),
}),
/**
* GET /api/lanes/clone/progress/:token — a clone's place in the
* machine-wide queue, then git's own progress; 404 until the POST
* carrying `progressToken` arrives, and a minute after it ends.
*/
cloneProgress: (token: string) =>
request<CloneProgressView>(`/lanes/clone/progress/${encodeURIComponent(token)}`),
/**
* GET /api/lanes/pipelines — every pipeline template the server can render
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
@@ -3977,6 +3988,9 @@ export const api = {
url: string;
name?: string;
credentials?: { username: string; password: string; remember?: boolean };
/** Blobless clone, like {@link lanes.clone}'s `partial`. */
partial?: boolean;
progressToken?: string;
}
) =>
request<LaneSourcesView & { path: string }>(`/lanes/${id}/sources/clone`, {
@@ -5290,6 +5304,23 @@ export interface LaneSource {
}
/** Response shape of GET /api/lanes/:id/sources (and of every sources write). */
/** `GET /api/lanes/clone/progress/:token` — see server/lib/clone-queue.js. */
export interface CloneProgressView {
state: "queued" | "running" | "done" | "failed";
/** 1-based place among the waiting clones while `queued`. */
position: number | null;
/** Clones running / waiting machine-wide, and how many may run at once. */
running: number;
waiting: number;
limit: number;
phase: "remote" | "receiving" | "resolving" | "checkout" | "other" | null;
/** git's own words for the phase (shown for `other`). */
label: string | null;
percent: number | null;
/** e.g. "450/1000, 120.50 MiB | 5.20 MiB/s". */
detail: string | null;
}
export interface LaneSourcesView {
instructions: string;
/** The workspace's tool can take folders and instructions (claude, codex). */
+4 -3
View File
@@ -14,9 +14,10 @@
* terminal moves the unsent prompt across (`takeRunInput` reads the TUI's
* input box off the screen and empties it; the chat's draft is pasted back
* without Enter), so the half-typed text is never left behind on the other side.
* Attached images' paths go after the text, each in a paste of its own: `claude`
* turns a paste that is only an image's path into `[Image #n]`, but left a
* Windows path (`C:\…\drops\…png`) inside a paste of text as a plain path.
* Attached images' paths go after the text, each in a paste of its own — how
* `codex` attaches one; a `claude` pane gets every file as an `@` mention in
* the text instead (`promptForPane`), since a pasted path stayed a plain
* `C:\…\drops\…png` there on Windows.
* The Chat view also answers a menu the CLI asks with (`parsePaneMenu` reads
* it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the
* arrows and Enter, or type into its "Type something" row).
+16 -2
View File
@@ -4,8 +4,9 @@
* file or folder dragged from the Files panel (`ATTACH_MIME`, read back by
* `PromptEditor`'s drop) or from a Chat pane's activity panel
* (`dragAttachments`, `dragSkill`: a skill becomes the prompt's `/command`) and the files uploaded through a prompt box in the
* last day (`recentUploads`, kept in this browser). An attachment is a path
* sent with the prompt, so nothing here touches the server.
* last day (`recentUploads`, kept in this browser), and `claudeMention`, the
* `@` form a file is handed to `claude` in. An attachment is a path sent with
* the prompt, so nothing here touches the server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -101,6 +102,19 @@ export function droppedText(
}
}
/** An attached file as Claude Code's `@` mention, which `claude` reads at
* submit — an image as an image — whether it came on the command line or in
* a paste. A bare pasted PATH becomes `[Image #n]` only when the TUI catches
* the paste on its own and finishes before Enter: on Windows (psmux) it
* stayed a plain `C:\…\drops\…png` in the prompt, and on the command line
* a path is never an image at all. A Windows path's backslashes become `/`
* (Windows takes either); a path with a space is quoted. */
export function claudeMention(path: string): string {
if (path.startsWith("@")) return path;
const p = /^[A-Za-z]:\\/.test(path) ? path.replace(/\\/g, "/") : path;
return /\s/.test(p) ? `@"${p}"` : `@${p}`;
}
export const SKILL_MIME = "application/x-agenthub-skill";
// A skill's name as Claude Code types it: `name` or `plugin:name`.
const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/;
+49 -9
View File
@@ -1,22 +1,37 @@
/**
* @file sessionDefaults.ts
* @description The account's own starting choices for new work, picked in the
* first-run setup (docs/plans/onboarding) and synced per account like every
* other preference (`lib/userPrefs.ts`): the AI a new workspace starts on, and
* the permission mode a new Claude session starts in. Bypass is NEVER one of
* them — it stays the per-session switch (CLAUDE.md), so `bypassPermissions`
* is refused on read and on write.
* first-run setup (docs/plans/onboarding) or Settings → AI tools → Session
* defaults, and synced per account like every other preference
* (`lib/userPrefs.ts`): the AI a new workspace starts on, the permission mode
* a new Claude session starts in, and a new Codex session's sandbox, approval
* and bypass. Bypass (Claude's `bypassPermissions`, Codex's bypass switch) may
* be a default only because the user turned it on in Settings behind a
* confirm — the first-run setup never offers it, and the pet chat never
* starts in it (`readDefaultMode({ safe: true })`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { PermissionMode } from "./api";
import type { CodexApproval, CodexSandbox, PermissionMode } from "./api";
const TOOL_KEY = "agenthub.defaultTool";
const MODE_KEY = "agenthub.permissionMode";
const CODEX_SANDBOX_KEY = "agenthub.codex.sandbox";
const CODEX_APPROVAL_KEY = "agenthub.codex.approval";
const CODEX_BYPASS_KEY = "agenthub.codex.bypass";
/** The permission modes a default may be. */
export const DEFAULT_MODES = ["auto", "acceptEdits", "default", "plan"] as const;
export const DEFAULT_MODES = ["auto", "acceptEdits", "default", "plan", "bypassPermissions"] as const;
export type DefaultMode = (typeof DEFAULT_MODES)[number];
/** The ones the first-run setup offers: never bypass. */
export const SAFE_MODES = DEFAULT_MODES.filter((m) => m !== "bypassPermissions");
export const CODEX_SANDBOXES: readonly CodexSandbox[] = [
"read-only",
"workspace-write",
"danger-full-access",
];
export const CODEX_APPROVALS: readonly CodexApproval[] = ["on-request", "never"];
function read(key: string): string | null {
try {
@@ -37,11 +52,36 @@ function write(key: string, value: string): void {
export const readDefaultTool = (): string | null => read(TOOL_KEY);
export const writeDefaultTool = (id: string): void => write(TOOL_KEY, id);
/** The permission mode a new Claude session starts in ("auto" when none was picked). */
export function readDefaultMode(): PermissionMode {
/** The permission mode a new Claude session starts in ("auto" when none was
* picked); `safe` turns a bypass default into "auto". */
export function readDefaultMode({ safe = false }: { safe?: boolean } = {}): PermissionMode {
const v = read(MODE_KEY);
if (safe && v === "bypassPermissions") return "auto";
return (DEFAULT_MODES as readonly string[]).includes(v ?? "") ? (v as DefaultMode) : "auto";
}
export function writeDefaultMode(mode: DefaultMode): void {
if ((DEFAULT_MODES as readonly string[]).includes(mode)) write(MODE_KEY, mode);
}
export interface CodexDefaults {
sandbox: CodexSandbox;
approval: CodexApproval;
bypass: boolean;
}
/** A new Codex session's sandbox, approval and bypass (Codex's own defaults when none was picked). */
export function readCodexDefaults(): CodexDefaults {
const sandbox = read(CODEX_SANDBOX_KEY) as CodexSandbox | null;
const approval = read(CODEX_APPROVAL_KEY) as CodexApproval | null;
return {
sandbox: sandbox && CODEX_SANDBOXES.includes(sandbox) ? sandbox : "workspace-write",
approval: approval && CODEX_APPROVALS.includes(approval) ? approval : "on-request",
bypass: read(CODEX_BYPASS_KEY) === "true",
};
}
export function writeCodexDefaults(patch: Partial<CodexDefaults>): void {
if (patch.sandbox && CODEX_SANDBOXES.includes(patch.sandbox)) write(CODEX_SANDBOX_KEY, patch.sandbox);
if (patch.approval && CODEX_APPROVALS.includes(patch.approval))
write(CODEX_APPROVAL_KEY, patch.approval);
if (patch.bypass !== undefined) write(CODEX_BYPASS_KEY, String(patch.bypass === true));
}
+3
View File
@@ -38,6 +38,9 @@ export const PREF_KEYS = [
"agent-monitor-notifications",
"agenthub.defaultTool",
"agenthub.permissionMode",
"agenthub.codex.sandbox",
"agenthub.codex.approval",
"agenthub.codex.bypass",
"agenthub.tour",
"agenthub.workspace.allTabs",
] as const;
+2
View File
@@ -67,6 +67,7 @@ import { SnapshotSettings } from "../components/settings/SnapshotSettings";
import { ReportSettings } from "../components/settings/ReportSettings";
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
@@ -292,6 +293,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
render: () => (
<div className="space-y-6">
<AgentInstructionsSettings />
<SessionDefaultsSettings />
<AiToolsSettings settingsHref={toolHref} />
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
<ArtifactsHubSettings />
@@ -7239,7 +7239,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.11 (rebuild client to match)
UI build v2.8.12 (rebuild client to match)
</span>
</p>
</div>
+24 -4
View File
@@ -110,7 +110,7 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
| `PUT /api/me/avatar` (raw image bytes) | the account's picture (PNG, JPEG, GIF or WebP by magic bytes — never SVG), stored as `<data dir>/avatars/u<id>.<ext>`; the `/api/me/account` shape back with `user.avatarAt` set. `400 EBADIMAGE`, `413 ETOOBIG` over 1 MB, `403 ELOCALMODE`. Login cookie only, audited |
| `DELETE /api/me/avatar` | removes it (`avatarAt` back to `null`); deleting the user removes it too |
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.terminal.fontSize`, `agenthub.ui.fontSize`, `agenthub.pet`, `agenthub.pet.size`, `agenthub.pet.names`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (never `bypassPermissions` — bypass stays the per-session switch), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.terminal.fontSize`, `agenthub.ui.fontSize`, `agenthub.pet`, `agenthub.pet.size`, `agenthub.pet.names`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (`bypassPermissions` allowed — set only from Settings → AI tools → Session defaults, behind a confirm), `agenthub.codex.sandbox`, `agenthub.codex.approval`, `agenthub.codex.bypass` (`"true"`/`"false"`: a new Codex session's defaults), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
`GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `agenthub version` or Settings → System to confirm client and server builds match after deploy.
@@ -351,11 +351,31 @@ check of that route applies, and opens a workspace on the clone.
#### Clone a repository into a new workspace
```
POST /api/lanes/clone Body: { url, parent, name?, title?, tool?, credentials?: { username, password, remember? } }
POST /api/lanes/clone Body: { url, parent, name?, title?, tool?, credentials?: { username, password, remember? }, partial?, progressToken? }
→ 201 { lane, created: true, path }
GET /api/lanes/clone/progress/:token
→ 200 { state: queued|running|done|failed, position, running, waiting, limit,
phase: remote|receiving|resolving|checkout|other|null, label, percent, detail }
```
Add workspace → **Clone**. `url` passes the same allowlist as the git bar's
Add workspace → **Clone**. Clones are queued machine-wide
(`server/lib/clone-queue.js`): at most `AGENTHUB_CLONE_CONCURRENCY` run at
once (default half the CPU cores, at least 1), the rest wait in arrival order
with the request held open. Each runs under `nice -n 10` / `ionice -c2 -n7`
with `pack.threads` = its share of the cores (not persisted), and is killed
after 30 minutes of running — so several ~1 GB clones no longer starve the
dashboard and the panes. `partial: true` (the form's **Partial clone** box;
`400 EBADFIELD` when not a boolean) makes it blobless (`--filter=blob:none`):
every commit and tree, but a file's contents only when a checkout needs them —
far less to download and unpack for a big repo; checking out an old revision
later fetches from the remote. `progressToken` (16–64 of `[A-Za-z0-9_-]`, unused;
else `400 EBADFIELD`) is the browser's own name for the clone: while the POST is
held open, `GET /api/lanes/clone/progress/:token` answers its 1-based
`position` among the waiting clones, then git's `--progress` line parsed into
`phase`, `percent` and `detail` (`"450/1000, 120.50 MiB | 5.20 MiB/s"`) — the
form polls it every second (`CloneProgress.tsx`). Only the user who started
the clone sees it (404 otherwise), and it is dropped a minute after the clone
ends. `url` passes the same allowlist as the git bar's
`publish` (`https://` with no userinfo, `ssh://`, scp-like `user@host:path` —
else `400 EBADURL`); `name` (default: the URL's last segment without `.git`)
is one plain folder name that must not exist (`400 EBADNAME`, `409 EEXISTS`);
@@ -1600,7 +1620,7 @@ What a workspace's home page edits (`server/lib/lane-sources.js`, `server/routes
- **Instructions** — the `instructions` key is required (`400 EBADFIELD` without it), a string or `null`; blank clears; over 16 000 characters is `413 ETOOBIG`. The lane is re-broadcast (`lane_update`). The text is stored in `lanes.instructions` but is NOT part of the lane payload (`GET /api/lanes`, `lane_update`) — read it here.
- **Folder** — must be absolute, exist and be a directory (`400 EBADPATH`), must not be or contain the OS home (`400 EBADHOME`), and must not be the workspace's own folder or one it already has (`400 EDUP`). Stored by real path. Removing it only drops the row.
- **Clone** — clones a related repo BESIDE the workspace folder (into its parent; no path is taken from the request) and adds it as a Folder. The clone is `POST /api/lanes/clone`'s own: the same URL allowlist (`400 EBADURL`), one plain `name` (default: the repo's), `409 EEXISTS` for a taken name, run as the folder's account with that account's git identity, `409 ECREDENTIALS` (with `auth: {protocol, host}`) / `ESSHAUTH` / `ENOREPO`; a tool that takes no folders is `400 EUNSUPPORTED`. Answers the view plus `path`. Removing the folder later never deletes the clone. The workspace home's **Add folder** form takes several URLs, one per line, and clones them in turn. Before cloning it checks each URL against `GET /api/me/git`: an SSH link (`ssh://…`, `user@host:path`) while the account has no SSH key is not sent at all and points to Settings → Git (make a key, add its public key to the host), and an HTTPS host with no saved login is flagged with an optional login row — a public repo needs none. The server's own `ECREDENTIALS`/`ESSHAUTH` answers still decide.
- **Clone** — clones a related repo BESIDE the workspace folder (into its parent; no path is taken from the request) and adds it as a Folder. The clone is `POST /api/lanes/clone`'s own: the same URL allowlist (`400 EBADURL`), one plain `name` (default: the repo's), the same `partial` and `progressToken`, `409 EEXISTS` for a taken name, run as the folder's account with that account's git identity, `409 ECREDENTIALS` (with `auth: {protocol, host}`) / `ESSHAUTH` / `ENOREPO`; a tool that takes no folders is `400 EUNSUPPORTED`. Answers the view plus `path`. Removing the folder later never deletes the clone. The workspace home's **Add folder** form takes several URLs, one per line, and clones them in turn. Before cloning it checks each URL against `GET /api/me/git`: an SSH link (`ssh://…`, `user@host:path`) while the account has no SSH key is not sent at all and points to Settings → Git (make a key, add its public key to the host), and an HTTPS host with no saved login is flagged with an optional login row — a public repo needs none. The server's own `ECREDENTIALS`/`ESSHAUTH` answers still decide.
- **File** — up to 10 MB (`413 ETOOBIG`), not empty (`400 EEMPTY`). AgentHub stores it under `<data dir>/workspace-context/<lane id>/` with a name it derives from `X-File-Name` (directory parts and path characters dropped, `name (2).ext` on a clash), so a request never names a path. It is a COPY taken at upload: later edits to the original are not seen — remove and upload it again, or add its directory as a Folder (read live from disk). Removing it deletes that copy. At most 50 sources per workspace (`400 ETOOMANY`).
- **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `<CLAUDE_HOME>/projects/<encoded cwd>/memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content.
- Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`.
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -2,7 +2,7 @@
Asked 2026-10-08 (modelled on Orca's 3-step welcome). Decisions:
- **Bypass is never remembered** (CLAUDE.md rule). The wizard remembers a default *permission mode* (Auto / Accept edits / Ask / Plan); bypass stays the red per-session switch.
- **The wizard never offers bypass.** It remembers a default *permission mode* (Auto / Accept edits / Ask / Plan). Since 2026-10-19 Settings → AI tools → **Session defaults** may make Bypass the default (Claude's `bypassPermissions`, Codex's bypass switch), behind a confirm; the pet chat still never starts in it.
- **Per account, on the server.** The choices follow the account to any browser. Local mode is one account, `local`.
- **The tutorial is a step-by-step spotlight tour.**
@@ -24,7 +24,7 @@ Asked 2026-10-08 (modelled on Orca's 3-step welcome). Decisions:
- **On save:** wrapping `localStorage.setItem` for `PREF_KEYS` pushes each change (debounced).
- [x] New preference keys:
- `agenthub.defaultTool`, read by AddLaneModal before the install default;
- `agenthub.permissionMode`, read by WorkspaceHome. `bypassPermissions` is never accepted here.
- `agenthub.permissionMode`, read by WorkspaceHome. `bypassPermissions` is accepted only as set from Settings → Session defaults.
- [x] `components/onboarding/OnboardingWizard.tsx`, mounted in Layout. It shows when the account is not onboarded AND has no workspace (an account that already has one is marked onboarded silently). Steps:
1. Default agent (detected tools as a grid, plus "show more") and the default permission mode.
2. Appearance: System, Dark and Light as preview cards, a theme grid for dark and for light below them, then language and terminal font.
+676 -2
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.8.11
version: 2.8.12
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
@@ -68,6 +68,10 @@ tags:
description: 'The File Explorer on a workspace or a user''s home: list, make, rename, move, copy, delete into a restorable trash, streamed upload, zip download. Writes run as the folder''s account; a taken name is settled by `conflict`.'
- name: Git identity
description: A user's own SSH key and HTTPS logins and commit author, and cloning a repository into a new workspace with them.
- name: Artifacts
description: 'What agents produced, shared as a SNAPSHOT: a claude.ai page link, a pinned or uploaded file, a skill or a plugin. A viewer reads only the snapshot copy (sandbox CSP, nosniff); an install writes into the caller''s own account only. Every write is same-origin.'
- name: Host
description: 'Administration → Server: the machine''s hardware, load, disks and each user''s share of CPU and memory. Read-only, staff only in server mode.'
components:
parameters:
SessionIdPath:
@@ -3859,7 +3863,7 @@ components:
example: claude-opus-4-8
resumeSessionId:
type: string
description: Optional Claude session id to resume via `--resume`. Must match /^[A-Za-z0-9-]{8,}$/ and requires conversation mode, else EBADSESSION / EBADMODE.
description: Optional Claude session id to resume via `--resume`. Must match /^[A-Za-z0-9_-]{8,}$/ and requires conversation mode, else EBADSESSION / EBADMODE.
example: a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d
effort:
type: string
@@ -4218,6 +4222,9 @@ components:
resume:
type: boolean
description: Offers Resume last session
resumeById:
type: boolean
description: Can resume a named session (Claude, Codex, a catalog `resumeById` such as omp)
bypass:
type: boolean
description: Offers the per-run Bypass permissions switch
@@ -4928,6 +4935,71 @@ components:
- replace
default: fail
description: 'A taken name: `fail` answers 409 EEXISTS with the names, `skip` leaves it, `keep` uses a free `name (1).ext`, `replace` sends the old one to the trash first.'
Artifact:
type: object
properties:
id:
type: string
kind:
type: string
enum:
- page
- file
- skill
- plugin
title:
type: string
description:
type: string
nullable: true
ownerId:
type: integer
nullable: true
owner:
type: string
nullable: true
description: Display name or username.
mine:
type: boolean
canChange:
type: boolean
laneId:
type: integer
nullable: true
sessionId:
type: string
nullable: true
source:
type: string
nullable: true
description: 'Plugin: `name@marketplace`.'
market:
type: string
nullable: true
description: 'Plugin: owner/repo, git URL or marketplace.json URL.'
url:
type: string
nullable: true
description: 'Page: a https://claude.ai/… link.'
files:
type: array
items:
type: object
description: The files the snapshot recorded.
size:
type: integer
nullable: true
shared:
type: boolean
sharedAt:
type: string
nullable: true
installs:
type: integer
createdAt:
type: string
updatedAt:
type: string
paths:
/api/health:
get:
@@ -16290,9 +16362,34 @@ paths:
type: string
remember:
type: boolean
partial:
type: boolean
description: 'Blobless clone (`--filter=blob:none`): file contents fetched on checkout.'
progressToken:
type: string
pattern: ^[A-Za-z0-9_-]{16,64}$
description: The caller's name for this clone, to poll `/api/lanes/clone/progress/{token}`.
responses:
'201':
description: '{lane, created: true, path}'
/api/lanes/clone/progress/{token}:
get:
tags:
- Git identity
summary: A clone's place in the queue, then git's progress
description: Clones run a few at a time machine-wide (`AGENTHUB_CLONE_CONCURRENCY`). Only the user who started the clone sees it; kept 60 s after it ends.
operationId: cloneProgress
parameters:
- name: token
in: path
required: true
schema:
type: string
responses:
'200':
description: '{state: queued|running|done|failed, position, running, waiting, limit, phase: remote|receiving|resolving|checkout|other|null, label, percent, detail}'
'404':
description: ENOTFOUND
/api/me/git:
get:
tags:
@@ -16491,6 +16588,583 @@ paths:
description: The identity.
'400':
description: EBADAUTHOR
/api/artifacts:
get:
tags:
- Artifacts
summary: List artifacts
operationId: listArtifacts
description: A developer sees their own rows plus shared ones; staff see every row in `hub` scope.
parameters:
- name: scope
in: query
schema:
type: string
enum:
- mine
- hub
- name: kind
in: query
schema:
type: string
enum:
- page
- file
- skill
- plugin
- name: q
in: query
schema:
type: string
- name: laneId
in: query
required: false
schema:
type: integer
- name: sessionId
in: query
schema:
type: string
responses:
'200':
description: '`{items}`.'
content:
application/json:
schema:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/Artifact'
/api/artifacts/settings:
get:
tags:
- Artifacts
summary: Hub switches and what the caller may do
operationId: getArtifactSettings
responses:
'200':
description: '`{serverMode, canShare, installsEnabled, canManage}`.'
content:
application/json:
schema:
type: object
properties:
serverMode:
type: boolean
canShare:
type: boolean
installsEnabled:
type: boolean
canManage:
type: boolean
put:
tags:
- Artifacts
summary: Turn installs on or off (admin; any caller in local mode)
operationId: setArtifactSettings
description: Audited in server mode (Settings → AI tools → Artifacts Hub).
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- installsEnabled
properties:
installsEnabled:
type: boolean
responses:
'200':
description: '`{installsEnabled}`.'
'400':
description: EBADFIELD
'403':
description: EADMIN / EBADORIGIN
/api/artifacts/candidates:
get:
tags:
- Artifacts
summary: Documents a workspace's sessions wrote (to pin)
operationId: listArtifactCandidates
parameters:
- name: laneId
in: query
required: false
schema:
type: integer
responses:
'200':
description: '`{items: [{path, sessionId, at, exists, pinned}]}`.'
'404':
description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.'
/api/artifacts/skills:
get:
tags:
- Artifacts
summary: Skills the caller can share (user + workspace)
operationId: listArtifactSkills
parameters:
- name: laneId
in: query
required: false
schema:
type: integer
responses:
'200':
description: '`{items: [{name, from: "user"|"workspace"}]}`.'
/api/artifacts/page:
post:
tags:
- Artifacts
summary: Add a claude.ai page link
operationId: addArtifactPage
description: Only `https://claude.ai/…` is accepted; one row per owner and link.
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- url
properties:
url:
type: string
title:
type: string
description:
type: string
responses:
'201':
description: Created.
content:
application/json:
schema:
type: object
properties:
item:
$ref: '#/components/schemas/Artifact'
'400':
description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.'
/api/artifacts/file:
post:
tags:
- Artifacts
summary: Pin a workspace file (snapshotted)
operationId: pinArtifactFile
description: Read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB) and copied once.
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- laneId
- path
properties:
laneId:
type: integer
path:
type: string
title:
type: string
sessionId:
type: string
responses:
'201':
description: Created.
content:
application/json:
schema:
type: object
properties:
item:
$ref: '#/components/schemas/Artifact'
'400':
description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.'
/api/artifacts/file/upload:
post:
tags:
- Artifacts
summary: Keep one uploaded file (≤ 25 MB)
operationId: uploadArtifactFile
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required:
- files
properties:
files:
type: string
format: binary
title:
type: string
description:
type: string
responses:
'201':
description: Created.
content:
application/json:
schema:
type: object
properties:
item:
$ref: '#/components/schemas/Artifact'
'400':
description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.'
/api/artifacts/skill:
post:
tags:
- Artifacts
summary: Share an existing skill
operationId: addArtifactSkill
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
properties:
name:
type: string
laneId:
type: integer
title:
type: string
description:
type: string
responses:
'201':
description: Created.
content:
application/json:
schema:
type: object
properties:
item:
$ref: '#/components/schemas/Artifact'
'400':
description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.'
/api/artifacts/skill/upload:
post:
tags:
- Artifacts
summary: 'Skills from files: a file, a folder, a .zip/.skill or several skills'
operationId: uploadArtifactSkills
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.'
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required:
- files
properties:
files:
type: array
items:
type: string
format: binary
paths:
type: string
name:
type: string
title:
type: string
description:
type: string
responses:
'201':
description: Created.
content:
application/json:
schema:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/Artifact'
'400':
description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.'
/api/artifacts/skill/new:
post:
tags:
- Artifacts
summary: Write a skill in the dialog
operationId: writeArtifactSkill
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
- description
- content
properties:
name:
type: string
description:
type: string
content:
type: string
title:
type: string
responses:
'201':
description: Created.
content:
application/json:
schema:
type: object
properties:
item:
$ref: '#/components/schemas/Artifact'
items:
type: array
items:
$ref: '#/components/schemas/Artifact'
'400':
description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.'
/api/artifacts/plugin/inspect:
post:
tags:
- Artifacts
summary: Read a marketplace's plugin list
operationId: inspectArtifactMarket
description: Clones a user-named https link only to read `marketplace.json`; no credential is ever sent.
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- market
properties:
market:
type: string
responses:
'200':
description: '`{market, name, description, plugins: [{name, key, description, version, category}]}`.'
'502':
description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.'
/api/artifacts/plugin:
post:
tags:
- Artifacts
summary: Share a plugin from a marketplace
operationId: addArtifactPlugin
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- key
- market
properties:
key:
type: string
description: '`name@marketplace`.'
market:
type: string
title:
type: string
description:
type: string
responses:
'201':
description: Created.
content:
application/json:
schema:
type: object
properties:
item:
$ref: '#/components/schemas/Artifact'
'400':
description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.'
/api/artifacts/{id}:
get:
tags:
- Artifacts
summary: One artifact
operationId: getArtifact
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: '`{item}`.'
content:
application/json:
schema:
type: object
properties:
item:
$ref: '#/components/schemas/Artifact'
'404':
description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.'
patch:
tags:
- Artifacts
summary: Rename, describe, share or unshare (owner or admin)
operationId: updateArtifact
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required: []
properties:
title:
type: string
description:
type: string
shared:
type: boolean
source:
type: string
description: Plugin only.
market:
type: string
description: Plugin only.
responses:
'200':
description: '`{item}`.'
content:
application/json:
schema:
type: object
properties:
item:
$ref: '#/components/schemas/Artifact'
'403':
description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.'
'404':
description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.'
delete:
tags:
- Artifacts
summary: Delete an artifact and its snapshot (owner or admin)
operationId: deleteArtifact
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: '`{ok: true}`.'
'403':
description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.'
'404':
description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.'
/api/artifacts/{id}/raw:
get:
tags:
- Artifacts
summary: One recorded file of a snapshot
operationId: getArtifactRaw
description: Always served under a `sandbox` CSP with `nosniff`; an unlisted type, or `download=1`, is an attachment. Only a file the snapshot recorded can be named.
parameters:
- name: id
in: path
required: true
schema:
type: string
- name: path
in: query
required: true
schema:
type: string
- name: download
in: query
schema:
type: string
enum:
- '1'
responses:
'200':
description: The file bytes.
'400':
description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.'
'404':
description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.'
/api/artifacts/{id}/install:
post:
tags:
- Artifacts
summary: Install a shared skill or plugin into the caller's own account
operationId: installArtifact
description: A skill goes to the caller's `~/.claude/skills/` (never over an existing one without `replace`; the old one is moved aside). A plugin runs `claude plugin install --scope user` as the caller.
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required: []
properties:
replace:
type: boolean
responses:
'200':
description: '`{installed, replaced?}`.'
'403':
description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.'
'409':
description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.'
'502':
description: 'Error `{error:{code,message}}`: 400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.'
/api/host:
get:
tags:
- Host
summary: The machine and each user's live share of it
operationId: getHost
description: 'Cached 3 s. `usage` is null off Linux. Server mode: admin/analyst only (403 EADMIN for a developer).'
responses:
'200':
description: '`{at, hostname, os, uptime, cpu, memory, disks, homesMount, gpus, usage}` — see docs/API.md.'
content:
application/json:
schema:
type: object
'500':
description: EHOST
/api/settings/import:
post:
tags:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.11",
"version": "2.8.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.11",
"version": "2.8.12",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.11",
"version": "2.8.12",
"bin": {
"agenthub": "bin/agenthub.js"
},
+3
View File
@@ -100,6 +100,9 @@ const EXPECTED_API_PATHS = [
"/api/webhooks/{id}",
"/api/webhooks/{id}/test",
"/api/webhooks/{id}/deliveries",
"/api/artifacts",
"/api/artifacts/{id}/install",
"/api/host",
"/api/openapi.json",
"/api/docs",
"/api/redoc",
+132
View File
@@ -0,0 +1,132 @@
/**
* @file Tests for `server/lib/clone-queue.js`: no more clones run at once
* than the limit, the rest start in arrival order as slots free, a failing
* clone frees its slot too, the per-clone thread share, and a tracked
* clone's progress: its queue position, git's --progress lines parsed, and
* shown only to its owner.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const os = require("node:os");
const queue = require("../lib/clone-queue");
const saved = process.env.AGENTHUB_CLONE_CONCURRENCY;
function gate() {
let open;
const p = new Promise((r) => (open = r));
return { p, open };
}
describe("clone-queue", () => {
afterEach(() => {
if (saved === undefined) delete process.env.AGENTHUB_CLONE_CONCURRENCY;
else process.env.AGENTHUB_CLONE_CONCURRENCY = saved;
});
it("runs at most the limit at once, the rest in arrival order", async () => {
process.env.AGENTHUB_CLONE_CONCURRENCY = "2";
const started = [];
const gates = [gate(), gate(), gate(), gate()];
const runs = gates.map((g, i) =>
queue.withCloneSlot(async () => {
started.push(i);
await g.p;
return i;
})
);
await new Promise((r) => setImmediate(r));
assert.deepEqual(started, [0, 1]);
assert.deepEqual(queue.cloneQueueState(), { running: 2, waiting: 2, limit: 2 });
gates[1].open();
await runs[1];
await new Promise((r) => setImmediate(r));
assert.deepEqual(started, [0, 1, 2]);
gates[0].open();
gates[2].open();
gates[3].open();
assert.deepEqual(await Promise.all(runs), [0, 1, 2, 3]);
assert.deepEqual(queue.cloneQueueState(), { running: 0, waiting: 0, limit: 2 });
});
it("frees the slot when a clone fails", async () => {
process.env.AGENTHUB_CLONE_CONCURRENCY = "1";
await assert.rejects(
queue.withCloneSlot(async () => {
throw new Error("boom");
}),
/boom/
);
assert.equal(await queue.withCloneSlot(async () => "ok"), "ok");
assert.equal(queue.cloneQueueState().running, 0);
});
it("splits the cores between the running clones", () => {
const cores = os.availableParallelism?.() ?? os.cpus().length;
process.env.AGENTHUB_CLONE_CONCURRENCY = "1";
assert.equal(queue.threads(), Math.max(1, cores));
process.env.AGENTHUB_CLONE_CONCURRENCY = String(cores * 4);
assert.equal(queue.threads(), 1);
delete process.env.AGENTHUB_CLONE_CONCURRENCY;
assert.equal(queue.concurrency(), Math.max(1, Math.floor(cores / 2)));
});
it("reports a tracked clone's queue position, then git's progress, to its owner only", async () => {
process.env.AGENTHUB_CLONE_CONCURRENCY = "1";
const first = gate();
const a = queue.track("tok-aaaaaaaaaaaaaaaa", 7);
const b = queue.track("tok-bbbbbbbbbbbbbbbb", 7);
const ra = queue.withCloneSlot(() => first.p, a);
let release;
const rb = queue.withCloneSlot(() => new Promise((r) => (release = r)), b);
await new Promise((r) => setImmediate(r));
assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).state, "running");
const waitingView = queue.progress("tok-bbbbbbbbbbbbbbbb", 7);
assert.equal(waitingView.state, "queued");
assert.equal(waitingView.position, 1);
assert.equal(waitingView.running, 1);
// Another user — or nobody — never sees it.
assert.equal(queue.progress("tok-bbbbbbbbbbbbbbbb", 8), null);
assert.equal(queue.progress("tok-cccccccccccccccc", 7), null);
queue.report(
a,
"Cloning into 'r'...\nremote: Counting objects: 100% (5/5), done.\n" +
"Receiving objects: 12% (120/1000), 3.00 MiB | 1.50 MiB/s\rReceiving objects: 45% (450/1000), 120.50 MiB | 5.20 MiB/s"
);
const v = queue.progress("tok-aaaaaaaaaaaaaaaa", 7);
assert.equal(v.phase, "receiving");
assert.equal(v.percent, 45);
assert.equal(v.detail, "450/1000, 120.50 MiB | 5.20 MiB/s");
queue.report(a, "Resolving deltas: 100% (300/300), done.\n");
assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).phase, "resolving");
assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).detail, "300/300");
first.open();
await ra;
queue.finish(a, true);
assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).state, "done");
await new Promise((r) => setImmediate(r));
assert.equal(queue.progress("tok-bbbbbbbbbbbbbbbb", 7).state, "running");
release();
await rb;
queue.finish(b, false);
assert.equal(queue.progress("tok-bbbbbbbbbbbbbbbb", 7).state, "failed");
});
it("refuses a malformed or reused token, and keeps progress out of an error", () => {
assert.throws(() => queue.track("short", null), { code: "EBADFIELD" });
assert.throws(() => queue.track("../../etc/passwd-xxxxxxxx", null), { code: "EBADFIELD" });
queue.track("tok-dddddddddddddddd", null);
assert.throws(() => queue.track("tok-dddddddddddddddd", null), { code: "EBADFIELD" });
assert.equal(
queue.stripProgress(
"Cloning into 'r'...\nReceiving objects: 45% (450/1000)\rfatal: early EOF\n"
),
"Cloning into 'r'...\nfatal: early EOF"
);
});
});
+59
View File
@@ -64,6 +64,8 @@ before(async () => {
"init",
]);
execFileSync("git", ["clone", "-q", "--bare", src, bare]);
// A forge serves a partial clone only when it allows filters.
execFileSync("git", ["-C", bare, "config", "uploadpack.allowFilter", "true"]);
remote = http.createServer((_req, res) => {
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' });
@@ -183,6 +185,61 @@ describe("POST /api/lanes/clone", () => {
assert.equal(r.body.error.code, "ENOREPO");
});
it("makes a blobless clone only when asked, and refuses a non-boolean", async () => {
const r = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "r-partial",
partial: true,
});
assert.equal(r.status, 201, JSON.stringify(r.body));
const dir = path.join(WORK, "r-partial");
assert.equal(fs.readFileSync(path.join(dir, "README.md"), "utf8"), "hi\n");
const cfg = (k) =>
execFileSync("git", ["-C", dir, "config", "--get", k], { encoding: "utf8" }).trim();
assert.equal(cfg("remote.origin.partialclonefilter"), "blob:none");
// The full clone above is not a promisor remote.
assert.throws(() =>
execFileSync("git", ["-C", path.join(WORK, "r"), "config", "--get", "remote.origin.promisor"])
);
const bad = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "r-bad",
partial: "yes",
});
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADFIELD");
});
it("reports a clone's progress by its token, and refuses a bad token", async () => {
const token = "progress-test-0000001";
const r = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "r-progress",
progressToken: token,
});
assert.equal(r.status, 201, JSON.stringify(r.body));
const p = await request("GET", `/api/lanes/clone/progress/${token}`);
assert.equal(p.status, 200);
assert.equal(p.body.state, "done");
assert.equal(p.body.position, null);
assert.equal(
(await request("GET", "/api/lanes/clone/progress/nope-nope-nope-nope")).status,
404
);
const bad = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "r-badtoken",
progressToken: "x",
});
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADFIELD");
assert.ok(!fs.existsSync(path.join(WORK, "r-badtoken")));
});
it("refuses a cross-origin request", async () => {
const r = await request(
"POST",
@@ -649,6 +706,8 @@ describe("git-identity, member account (S3, stubbed)", () => {
/^core\.sshCommand=ssh -o IdentityFile='.*\/id_ed25519' -o IdentityFile='.*\/id_rsa' -o IdentitiesOnly=yes$/
);
assert.deepEqual(args.slice(-3), ["--", "git@gh.test:o/r.git", "r"]);
// index-pack gets a share of the cores, never persisted into the clone.
assert.match(args.slice(0, i).join(" "), /-c pack\.threads=\d+/);
});
it("gives a member's fetch their own store, and puts a typed login in it only for the call", () => {
+182
View File
@@ -0,0 +1,182 @@
/**
* @file The machine-wide limit on `git clone`s running at once, and each
* clone's progress. A clone's index-pack resolves deltas on every core it
* finds, so a few big (~1 GB) clones started together pinned all of them and
* stalled the dashboard and every pane beside it. Clones past the limit wait
* here, in arrival order, and each one gets its share of the cores
* (`threads`) — the clone's own timeout starts only once it runs.
* `AGENTHUB_CLONE_CONCURRENCY` overrides the default (half the cores, at
* least 1). A clone the browser named with a token (`track`) reports its
* place in the queue, then git's own `--progress` lines (`report`), to
* `GET /api/lanes/clone/progress/:token` — only to the user who started it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
"use strict";
const os = require("node:os");
/** A finished clone's progress stays readable this long, then is dropped. */
const KEEP_DONE_MS = 60_000;
const TOKEN_RE = /^[A-Za-z0-9_-]{16,64}$/;
function cores() {
return Math.max(1, (os.availableParallelism?.() ?? os.cpus().length) || 1);
}
/** How many clones may run at once. */
function concurrency() {
const set = Number.parseInt(process.env.AGENTHUB_CLONE_CONCURRENCY, 10);
if (Number.isInteger(set) && set > 0) return set;
return Math.max(1, Math.floor(cores() / 2));
}
/** index-pack threads for one clone: the cores split between the running ones. */
function threads() {
return Math.max(1, Math.floor(cores() / concurrency()));
}
let running = 0;
/** `{start, job}` in arrival order; `job` is null for an untracked clone. */
const waiting = [];
/** token → job: `{owner, state, phase, label, percent, detail, finishedAt}`. */
const jobs = new Map();
function next() {
while (running < concurrency() && waiting.length) {
running++;
const { start, job } = waiting.shift();
if (job) job.state = "running";
start();
}
}
/** Runs `fn` once a clone slot is free; resolves or rejects with it. */
async function withCloneSlot(fn, job = null) {
await new Promise((start) => {
waiting.push({ start, job });
next();
});
try {
return await fn();
} finally {
running--;
next();
}
}
/** `{running, waiting, limit}` — for a status read or a test. */
function cloneQueueState() {
return { running, waiting: waiting.length, limit: concurrency() };
}
function sweep(now = Date.now()) {
for (const [token, job] of jobs) {
if (job.finishedAt && now - job.finishedAt > KEEP_DONE_MS) jobs.delete(token);
}
}
/**
* A progress record for the clone the browser named `token`, owned by
* `owner` (`scope.ownerFor`). Throws `EBADFIELD` for a malformed token or one
* still in use.
*/
function track(token, owner) {
sweep();
if (typeof token !== "string" || !TOKEN_RE.test(token) || jobs.has(token)) {
throw Object.assign(new Error("progressToken: 16–64 of [A-Za-z0-9_-], unused"), {
code: "EBADFIELD",
});
}
const job = {
owner,
state: "queued",
phase: null,
label: null,
percent: null,
detail: null,
finishedAt: null,
};
jobs.set(token, job);
return job;
}
/** Marks a tracked clone over: `done` or `failed`. */
function finish(job, ok) {
if (!job) return;
job.state = ok ? "done" : "failed";
job.finishedAt = Date.now();
}
/** git's progress words → a phase the UI translates; anything else is `other`. */
const PHASES = [
[/^remote: /, "remote"],
[/^Receiving objects/, "receiving"],
[/^Resolving deltas/, "resolving"],
[/^Updating files/, "checkout"],
];
const LINE_RE = /^((?:remote: )?[A-Za-z][A-Za-z ]*?):\s+(\d{1,3})% \(([^)]*)\)(.*)$/;
/**
* Feeds a chunk of `git clone --progress` stderr into `job`: the LAST
* complete progress line in it wins (git redraws a line with `\r`).
*/
function report(job, chunk) {
if (!job) return;
const lines = String(chunk).split(/[\r\n]+/);
for (let i = lines.length - 1; i >= 0; i--) {
const m = LINE_RE.exec(lines[i].trim());
if (!m) continue;
job.label = m[1].replace(/^remote: /, "");
job.phase = (PHASES.find(([re]) => re.test(m[1])) || [null, "other"])[1];
job.percent = Math.min(100, Number(m[2]));
// "1234/5678), 120.50 MiB | 5.20 MiB/s, done." → counts + size + speed
job.detail =
`${m[3]}${m[4]}`
.replace(/,\s*done\.?$/, "")
.replace(/^,\s*/, "")
.trim() || null;
return;
}
}
/**
* What `GET /api/lanes/clone/progress/:token` answers, or null when there is
* no such clone or it is not `owner`'s. A queued clone carries its 1-based
* `position` among the waiting ones.
*/
function progress(token, owner) {
sweep();
const job = jobs.get(token);
if (!job || job.owner !== owner) return null;
const position = waiting.findIndex((w) => w.job === job);
return {
state: job.state,
position: position >= 0 ? position + 1 : null,
...cloneQueueState(),
phase: job.phase,
label: job.label,
percent: job.percent,
detail: job.detail,
};
}
/** A progress-free tail of git's stderr for an error message. */
function stripProgress(stderr) {
return String(stderr || "")
.split(/[\r\n]+/)
.filter((l) => l.trim() && !LINE_RE.test(l.trim()))
.join("\n");
}
module.exports = {
withCloneSlot,
cloneQueueState,
concurrency,
threads,
track,
finish,
report,
progress,
stripProgress,
};
+28 -8
View File
@@ -44,8 +44,8 @@ const gitCredentials = require("./git-credentials");
const runAs = require("./run-as");
const HOST_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?$/;
/** A clone may take a while on a big repo; past this it is killed. */
const CLONE_TIMEOUT_MS = 10 * 60_000;
/** A clone may take a while on a big repo (~1 GB); past this it is killed. */
const CLONE_TIMEOUT_MS = 30 * 60_000;
const HELPER = path.join(__dirname, "git-credential-helper.js");
const err = (code, message) => Object.assign(new Error(message), { code });
@@ -841,15 +841,33 @@ function sshFailure(stderr) {
* Clone `url` into `<parent>/<name>` as the account owning `parent`, with
* that account's own identity. `once` is a login typed for this clone
* (`{username, password, remember}`) — https only, for this URL's host only.
* `partial` makes it a blobless clone (`--filter=blob:none`): every commit
* and tree, but a file's contents only when a checkout first needs them —
* far less to download and unpack for a big repo, at the cost of a fetch
* from the remote when an old revision is checked out later.
* Resolves `{dir, account}`; rejects with `ECREDENTIALS` (`{protocol, host}`
* attached) when git wanted a login it did not get, `ESSHAUTH` when the key
* was refused, else the git error.
*/
async function cloneRepo({ url, parent, name, once = null }) {
function cloneRepo({ job = null, ...opts }) {
// Waits for a free clone slot (clone-queue.js) before anything — a typed
// login sits in a member's store only while its own clone runs, and the
// timeout counts from there. `job` (clone-queue `track`) gets the queue
// position and git's progress.
const queue = require("./clone-queue");
return queue.withCloneSlot(
() => cloneNow(opts, queue.threads(), (chunk) => queue.report(job, chunk)),
job
);
}
async function cloneNow({ url, parent, name, once = null, partial = false }, threads, onProgress) {
const { git } = require("./worktree");
const account = accountForDir(parent);
const remote = remoteOf(url);
const pre = [];
// index-pack's delta threads: this clone's share of the cores, not all of
// them (clone-queue.js). Not persisted — a later fetch keeps git's default.
const pre = ["-c", `pack.threads=${threads}`];
const persist = [];
const env = {};
let restore = null;
@@ -911,9 +929,13 @@ async function cloneRepo({ url, parent, name, once = null }) {
let ok = false;
try {
await git(parent, [...pre, "clone", ...persist, "--", url, name], {
const filter = partial ? ["--filter=blob:none"] : [];
// --progress: git prints it to a pipe too, for the UI's progress line.
await git(parent, [...pre, "clone", ...persist, "--progress", ...filter, "--", url, name], {
timeout: CLONE_TIMEOUT_MS,
env,
background: true,
onStderr: onProgress,
});
ok = true;
} catch (e) {
@@ -932,9 +954,7 @@ async function cloneRepo({ url, parent, name, once = null }) {
}
throw err(
"ECLONE",
String(stderr || e.message)
.trim()
.slice(-800)
(require("./clone-queue").stripProgress(stderr) || e.message).trim().slice(-800)
);
} finally {
// A login typed without "remember" — or one that did not work — leaves
+43 -2
View File
@@ -19,6 +19,26 @@ const PROTECTED_BRANCHES = new Set(["main", "master"]);
/** Most status lines `statusCounts` reports by name; the counts stay exact. */
const STATUS_FILE_CAP = 20;
/**
* `nice`/`ionice` before a heavy git (a clone) so the dashboard and the panes
* beside it keep their share of the machine; children (index-pack, sudo's
* git) inherit both. Empty where neither exists (Windows).
*/
let lowPriorityPrefix = null;
function lowPriority() {
if (lowPriorityPrefix) return lowPriorityPrefix;
const find = (name) =>
["/usr/bin", "/bin"].map((d) => path.join(d, name)).find((f) => fs.existsSync(f));
const nice = process.platform === "win32" ? null : find("nice");
const ionice = process.platform === "linux" ? find("ionice") : null;
lowPriorityPrefix = [
...(nice ? [nice, "-n", "10"] : []),
// best-effort class, lowest level: never the idle class, which can wait forever
...(ionice ? [ionice, "-c", "2", "-n", "7"] : []),
];
return lowPriorityPrefix;
}
/**
* Promisified git wrapper. On failure, throws an Error with err.git = { args, code, stderr }.
* Treats zero exit code as success even if stderr has hints.
@@ -42,6 +62,10 @@ async function git(
encoding = "utf8",
// stdin (a patch for `apply -`): never a file the dashboard writes.
input = null,
// true: run under nice/ionice (a clone — see lowPriority above).
background = false,
// Called with each stderr chunk as it arrives (a clone's --progress).
onStderr = null,
} = {}
) {
// Build a clean environment: copy process.env but scrub git hook variables
@@ -88,6 +112,12 @@ async function git(
if (literal) pass.GIT_LITERAL_PATHSPECS = "1";
[cmd, argv] = require("./run-as").sudoArgv(member, "git", args, pass);
}
const gitCmd = cmd;
if (background && lowPriority().length) {
const [first, ...rest] = lowPriority();
argv = [...rest, cmd, ...argv];
cmd = first;
}
try {
const run =
@@ -100,7 +130,7 @@ async function git(
);
child.stdin.end(input);
});
const result = await run(cmd, argv, {
const pending = run(cmd, argv, {
cwd,
env,
// A read with its own size cap passes a bigger buffer and treats an
@@ -113,12 +143,23 @@ async function git(
timeout,
encoding,
});
// promisify(execFile) hands back its child; the stdin runner has none.
if (onStderr && pending.child?.stderr) {
pending.child.stderr.on("data", (d) => {
try {
onStderr(String(d));
} catch {
/* a progress reader never fails the git call */
}
});
}
const result = await pending;
return { stdout: result.stdout, stderr: result.stderr };
} catch (err) {
// ENOENT from execFile is ambiguous: a missing binary OR a missing cwd.
// With the folder present it is git itself that is absent — say so, with
// the fix, rather than `git status failed` (like ETMUXMISSING for tmux).
if (err.code === "ENOENT" && cmd === "git" && fs.existsSync(cwd)) {
if (err.code === "ENOENT" && gitCmd === "git" && fs.existsSync(cwd)) {
const error = new Error(
"git is not installed (or not on the dashboard's PATH), and every git action needs it. " +
"Install it (sudo apt install git / brew install git / https://git-scm.com/download/win), " +
+2
View File
@@ -19,6 +19,7 @@ const aiTools = require("./openapi-extra/ai-tools");
const misc = require("./openapi-extra/misc");
const auth = require("./openapi-extra/auth");
const files = require("./openapi-extra/files");
const artifacts = require("./openapi-extra/artifacts");
// Enriched overrides of already-documented endpoints — same operationId, tags,
// and request/response `$ref` schemas as the base, with added examples and
// richer descriptions. Listed last so they win the merge.
@@ -56,6 +57,7 @@ module.exports = combine(
misc,
auth,
files,
artifacts,
overrideSessionsAgents,
overrideCore,
overridePricingAlerts,
+359
View File
@@ -0,0 +1,359 @@
/**
* @file OpenAPI fragment for the Artifacts hub (`/api/artifacts/*`, server/routes/artifacts.js)
* and the Administration page's Server tab (`GET /api/host`, server/routes/host.js).
* Hand-written from the routes; docs/API.md § Artifacts hub is the long form.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const tags = [
{
name: "Artifacts",
description:
"What agents produced, shared as a SNAPSHOT: a claude.ai page link, a pinned or uploaded file, a skill or a plugin. A viewer reads only the snapshot copy (sandbox CSP, nosniff); an install writes into the caller's own account only. Every write is same-origin.",
},
{
name: "Host",
description:
"Administration → Server: the machine's hardware, load, disks and each user's share of CPU and memory. Read-only, staff only in server mode.",
},
];
const errorCodes =
"400 EBADURL/EBADPATH/EBADNAME/ENOSKILLMD/ENODESCRIPTION/EBADFIELD/EBADUPLOAD, 403 EFORBIDDEN/ESECRET/EFOREIGN/EINSTALLSOFF/EADMIN, 404 ENOTFOUND/ENOLANE/ENOFILE, 409 EEXISTS/ENOTSHARED/ENOACCOUNT/EUPGRADE, 413 ETOOBIG, 502 EPLUGIN/EMARKETFETCH/ENEEDLOGIN.";
const schemas = {
Artifact: {
type: "object",
properties: {
id: { type: "string" },
kind: { type: "string", enum: ["page", "file", "skill", "plugin"] },
title: { type: "string" },
description: { type: "string", nullable: true },
ownerId: { type: "integer", nullable: true },
owner: { type: "string", nullable: true, description: "Display name or username." },
mine: { type: "boolean" },
canChange: { type: "boolean" },
laneId: { type: "integer", nullable: true },
sessionId: { type: "string", nullable: true },
source: { type: "string", nullable: true, description: "Plugin: `name@marketplace`." },
market: { type: "string", nullable: true, description: "Plugin: owner/repo, git URL or marketplace.json URL." },
url: { type: "string", nullable: true, description: "Page: a https://claude.ai/… link." },
files: { type: "array", items: { type: "object" }, description: "The files the snapshot recorded." },
size: { type: "integer", nullable: true },
shared: { type: "boolean" },
sharedAt: { type: "string", nullable: true },
installs: { type: "integer" },
createdAt: { type: "string" },
updatedAt: { type: "string" },
},
},
};
const json = (properties, required = []) => ({
required: true,
content: { "application/json": { schema: { type: "object", required, properties } } },
});
const multipart = (properties, required = []) => ({
required: true,
content: { "multipart/form-data": { schema: { type: "object", required, properties } } },
});
const item = {
type: "object",
properties: { item: { $ref: "#/components/schemas/Artifact" } },
};
const items = {
type: "object",
properties: { items: { type: "array", items: { $ref: "#/components/schemas/Artifact" } } },
};
const idParam = { name: "id", in: "path", required: true, schema: { type: "string" } };
const laneQuery = { name: "laneId", in: "query", required: false, schema: { type: "integer" } };
const errs = { description: `Error \`{error:{code,message}}\`: ${errorCodes}` };
const created = (schema, description = "Created.") => ({
201: { description, content: { "application/json": { schema } } },
400: errs,
});
const paths = {
"/api/artifacts": {
get: {
tags: ["Artifacts"],
summary: "List artifacts",
operationId: "listArtifacts",
description:
"A developer sees their own rows plus shared ones; staff see every row in `hub` scope.",
parameters: [
{ name: "scope", in: "query", schema: { type: "string", enum: ["mine", "hub"] } },
{ name: "kind", in: "query", schema: { type: "string", enum: ["page", "file", "skill", "plugin"] } },
{ name: "q", in: "query", schema: { type: "string" } },
laneQuery,
{ name: "sessionId", in: "query", schema: { type: "string" } },
],
responses: { 200: { description: "`{items}`.", content: { "application/json": { schema: items } } } },
},
},
"/api/artifacts/settings": {
get: {
tags: ["Artifacts"],
summary: "Hub switches and what the caller may do",
operationId: "getArtifactSettings",
responses: {
200: {
description: "`{serverMode, canShare, installsEnabled, canManage}`.",
content: {
"application/json": {
schema: {
type: "object",
properties: {
serverMode: { type: "boolean" },
canShare: { type: "boolean" },
installsEnabled: { type: "boolean" },
canManage: { type: "boolean" },
},
},
},
},
},
},
},
put: {
tags: ["Artifacts"],
summary: "Turn installs on or off (admin; any caller in local mode)",
operationId: "setArtifactSettings",
description: "Audited in server mode (Settings → AI tools → Artifacts Hub).",
requestBody: json({ installsEnabled: { type: "boolean" } }, ["installsEnabled"]),
responses: {
200: { description: "`{installsEnabled}`." },
400: { description: "EBADFIELD" },
403: { description: "EADMIN / EBADORIGIN" },
},
},
},
"/api/artifacts/candidates": {
get: {
tags: ["Artifacts"],
summary: "Documents a workspace's sessions wrote (to pin)",
operationId: "listArtifactCandidates",
parameters: [laneQuery],
responses: {
200: { description: "`{items: [{path, sessionId, at, exists, pinned}]}`." },
404: errs,
},
},
},
"/api/artifacts/skills": {
get: {
tags: ["Artifacts"],
summary: "Skills the caller can share (user + workspace)",
operationId: "listArtifactSkills",
parameters: [laneQuery],
responses: { 200: { description: '`{items: [{name, from: "user"|"workspace"}]}`.' } },
},
},
"/api/artifacts/page": {
post: {
tags: ["Artifacts"],
summary: "Add a claude.ai page link",
operationId: "addArtifactPage",
description: "Only `https://claude.ai/…` is accepted; one row per owner and link.",
requestBody: json(
{ url: { type: "string" }, title: { type: "string" }, description: { type: "string" } },
["url"]
),
responses: created(item),
},
},
"/api/artifacts/file": {
post: {
tags: ["Artifacts"],
summary: "Pin a workspace file (snapshotted)",
operationId: "pinArtifactFile",
description:
"Read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB) and copied once.",
requestBody: json(
{
laneId: { type: "integer" },
path: { type: "string" },
title: { type: "string" },
sessionId: { type: "string" },
},
["laneId", "path"]
),
responses: created(item),
},
},
"/api/artifacts/file/upload": {
post: {
tags: ["Artifacts"],
summary: "Keep one uploaded file (≤ 25 MB)",
operationId: "uploadArtifactFile",
requestBody: multipart({
files: { type: "string", format: "binary" },
title: { type: "string" },
description: { type: "string" },
}, ["files"]),
responses: created(item),
},
},
"/api/artifacts/skill": {
post: {
tags: ["Artifacts"],
summary: "Share an existing skill",
operationId: "addArtifactSkill",
requestBody: json(
{
name: { type: "string" },
laneId: { type: "integer" },
title: { type: "string" },
description: { type: "string" },
},
["name"]
),
responses: created(item),
},
},
"/api/artifacts/skill/upload": {
post: {
tags: ["Artifacts"],
summary: "Skills from files: a file, a folder, a .zip/.skill or several skills",
operationId: "uploadArtifactSkills",
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.",
requestBody: multipart({
files: { type: "array", items: { type: "string", format: "binary" } },
paths: { type: "string" },
name: { type: "string" },
title: { type: "string" },
description: { type: "string" },
}, ["files"]),
responses: created(items),
},
},
"/api/artifacts/skill/new": {
post: {
tags: ["Artifacts"],
summary: "Write a skill in the dialog",
operationId: "writeArtifactSkill",
requestBody: json(
{
name: { type: "string" },
description: { type: "string" },
content: { type: "string" },
title: { type: "string" },
},
["name", "description", "content"]
),
responses: created({ type: "object", properties: { item: item.properties.item, items: items.properties.items } }),
},
},
"/api/artifacts/plugin/inspect": {
post: {
tags: ["Artifacts"],
summary: "Read a marketplace's plugin list",
operationId: "inspectArtifactMarket",
description: "Clones a user-named https link only to read `marketplace.json`; no credential is ever sent.",
requestBody: json({ market: { type: "string" } }, ["market"]),
responses: {
200: { description: "`{market, name, description, plugins: [{name, key, description, version, category}]}`." },
502: errs,
},
},
},
"/api/artifacts/plugin": {
post: {
tags: ["Artifacts"],
summary: "Share a plugin from a marketplace",
operationId: "addArtifactPlugin",
requestBody: json(
{
key: { type: "string", description: "`name@marketplace`." },
market: { type: "string" },
title: { type: "string" },
description: { type: "string" },
},
["key", "market"]
),
responses: created(item),
},
},
"/api/artifacts/{id}": {
get: {
tags: ["Artifacts"],
summary: "One artifact",
operationId: "getArtifact",
parameters: [idParam],
responses: { 200: { description: "`{item}`.", content: { "application/json": { schema: item } } }, 404: errs },
},
patch: {
tags: ["Artifacts"],
summary: "Rename, describe, share or unshare (owner or admin)",
operationId: "updateArtifact",
parameters: [idParam],
requestBody: json({
title: { type: "string" },
description: { type: "string" },
shared: { type: "boolean" },
source: { type: "string", description: "Plugin only." },
market: { type: "string", description: "Plugin only." },
}),
responses: { 200: { description: "`{item}`.", content: { "application/json": { schema: item } } }, 403: errs, 404: errs },
},
delete: {
tags: ["Artifacts"],
summary: "Delete an artifact and its snapshot (owner or admin)",
operationId: "deleteArtifact",
parameters: [idParam],
responses: { 200: { description: "`{ok: true}`." }, 403: errs, 404: errs },
},
},
"/api/artifacts/{id}/raw": {
get: {
tags: ["Artifacts"],
summary: "One recorded file of a snapshot",
operationId: "getArtifactRaw",
description:
"Always served under a `sandbox` CSP with `nosniff`; an unlisted type, or `download=1`, is an attachment. Only a file the snapshot recorded can be named.",
parameters: [
idParam,
{ name: "path", in: "query", required: true, schema: { type: "string" } },
{ name: "download", in: "query", schema: { type: "string", enum: ["1"] } },
],
responses: { 200: { description: "The file bytes." }, 400: errs, 404: errs },
},
},
"/api/artifacts/{id}/install": {
post: {
tags: ["Artifacts"],
summary: "Install a shared skill or plugin into the caller's own account",
operationId: "installArtifact",
description:
"A skill goes to the caller's `~/.claude/skills/` (never over an existing one without `replace`; the old one is moved aside). A plugin runs `claude plugin install --scope user` as the caller.",
parameters: [idParam],
requestBody: json({ replace: { type: "boolean" } }),
responses: {
200: { description: "`{installed, replaced?}`." },
403: errs,
409: errs,
502: errs,
},
},
},
"/api/host": {
get: {
tags: ["Host"],
summary: "The machine and each user's live share of it",
operationId: "getHost",
description:
"Cached 3 s. `usage` is null off Linux. Server mode: admin/analyst only (403 EADMIN for a developer).",
responses: {
200: {
description:
"`{at, hostname, os, uptime, cpu, memory, disks, homesMount, gpus, usage}` — see docs/API.md.",
content: { "application/json": { schema: { type: "object" } } },
},
500: { description: "EHOST" },
},
},
},
};
module.exports = { tags, schemas, paths };
+28
View File
@@ -253,12 +253,40 @@ const paths = {
remember: { type: "boolean" },
},
},
partial: {
type: "boolean",
description:
"Blobless clone (`--filter=blob:none`): file contents fetched on checkout.",
},
progressToken: {
type: "string",
pattern: "^[A-Za-z0-9_-]{16,64}$",
description:
"The caller's name for this clone, to poll `/api/lanes/clone/progress/{token}`.",
},
},
["url", "parent"]
),
responses: { 201: { description: "{lane, created: true, path}" } },
},
},
"/api/lanes/clone/progress/{token}": {
get: {
tags: ["Git identity"],
summary: "A clone's place in the queue, then git's progress",
description:
"Clones run a few at a time machine-wide (`AGENTHUB_CLONE_CONCURRENCY`). Only the user who started the clone sees it; kept 60 s after it ends.",
operationId: "cloneProgress",
parameters: [{ name: "token", in: "path", required: true, schema: { type: "string" } }],
responses: {
200: {
description:
"{state: queued|running|done|failed, position, running, waiting, limit, phase: remote|receiving|resolving|checkout|other|null, label, percent, detail}",
},
404: { description: "ENOTFOUND" },
},
},
},
"/api/me/git": {
get: {
tags: ["Git identity"],
+32
View File
@@ -777,6 +777,10 @@ async function cloneInto(req, res, body, before) {
res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
return null;
}
if (body.partial !== undefined && typeof body.partial !== "boolean") {
res.status(400).json({ error: { code: "EBADFIELD", message: "partial: boolean" } });
return null;
}
const once = body.credentials;
if (once != null && (typeof once !== "object" || Array.isArray(once))) {
res.status(400).json({ error: { code: "EBADCRED", message: "credentials: object" } });
@@ -813,17 +817,32 @@ async function cloneInto(req, res, body, before) {
return null;
}
const ownerId = scope.ownerFor(req);
// The browser's own name for this clone, so it can poll its progress.
const queue = require("../lib/clone-queue");
let job = null;
if (body.progressToken !== undefined) {
try {
job = queue.track(body.progressToken, ownerId);
} catch (err) {
res.status(400).json({ error: { code: err.code, message: err.message } });
return null;
}
}
try {
await before(target, ownerId);
await identity.cloneRepo({
job,
url,
parent: dir,
name,
once: once
? { username: once.username, password: once.password, remember: once.remember === true }
: null,
partial: body.partial === true,
});
queue.finish(job, true);
} catch (err) {
queue.finish(job, false);
const status = ["EBADCRED", "EBADHOST", "EBADTOOL", "EDISABLED"].includes(err.code)
? 400
: ["ECREDENTIALS", "ESSHAUTH", "ENOREPO", "EOWNERFOLDER", "EMEMBERGONE"].includes(err.code)
@@ -843,6 +862,19 @@ async function cloneInto(req, res, body, before) {
return { target, name, ownerId };
}
/**
* A clone's place in the machine-wide queue, then git's own progress
* (`server/lib/clone-queue.js`), by the `progressToken` its POST carried —
* only to the user who started it (404 otherwise, and once it is long over).
*/
router.get("/clone/progress/:token", (req, res) => {
const view = require("../lib/clone-queue").progress(req.params.token, scope.ownerFor(req));
if (!view) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such clone" } });
}
res.json(view);
});
router.post("/clone", sameOriginGuard, async (req, res) => {
const body = req.body || {};
// Before the clone: a workspace this user may not own there, or a tool
+3
View File
@@ -57,6 +57,9 @@ const PREF_KEYS = [
"agent-monitor-notifications",
"agenthub.defaultTool",
"agenthub.permissionMode",
"agenthub.codex.sandbox",
"agenthub.codex.approval",
"agenthub.codex.bypass",
"agenthub.tour",
"agenthub.chatUi",
"agenthub.workspace.allTabs",