Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49f87c4da3 | |||
| 1a6b550e89 | |||
| f8d07dab61 | |||
| 0434ffc750 | |||
| e2d8d314f6 |
@@ -165,12 +165,15 @@ export function MenuItem({
|
|||||||
danger,
|
danger,
|
||||||
disabled,
|
disabled,
|
||||||
testId,
|
testId,
|
||||||
|
title,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
|
/** Hover text — e.g. why a disabled item cannot run. */
|
||||||
|
title?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -179,6 +182,7 @@ export function MenuItem({
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
className={`${MENU_ITEM}${danger ? " !text-status-danger" : ""}`}
|
className={`${MENU_ITEM}${danger ? " !text-status-danger" : ""}`}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ export function PetChat({
|
|||||||
const r = await startInFreeTabDetailed(lane.id, {
|
const r = await startInFreeTabDetailed(lane.id, {
|
||||||
prompt,
|
prompt,
|
||||||
...(tool !== lane.tool ? { tool } : {}),
|
...(tool !== lane.tool ? { tool } : {}),
|
||||||
...(tool === "claude" ? { permissionMode: readDefaultMode() } : {}),
|
...(tool === "claude" ? { permissionMode: readDefaultMode({ safe: true }) } : {}),
|
||||||
});
|
});
|
||||||
remember({ laneId: r.laneId, runId: r.runId, tool });
|
remember({ laneId: r.laneId, runId: r.runId, tool });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
* A caller that knows the session has ended passes `ended`: nothing more is
|
* 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
|
* 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
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -622,6 +624,9 @@ function ConversationViewPane({
|
|||||||
waiting={waiting}
|
waiting={waiting}
|
||||||
onOpenTerminal={onOpenTerminal}
|
onOpenTerminal={onOpenTerminal}
|
||||||
stopped={stopped}
|
stopped={stopped}
|
||||||
|
sessionId={
|
||||||
|
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,14 @@
|
|||||||
* terminal (`waiting`) — and, like the terminal's status line, how long the
|
* 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
|
* 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
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -101,6 +108,7 @@ import {
|
|||||||
CornerDownRight,
|
CornerDownRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
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
|
/** Per-sender visual treatment for a transcript row. A JSONL `type:"user"` line
|
||||||
* is not always the human (tool results, harness task-notifications, the
|
* 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
|
/** Bumped by the chat's Stop: the turn on screen is over, even when the
|
||||||
* transcript never says so. */
|
* transcript never says so. */
|
||||||
stopped?: number;
|
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 */
|
/** Build a map from tool_use id → tool_result for matching */
|
||||||
@@ -641,6 +652,7 @@ function MessageListView({
|
|||||||
waiting = false,
|
waiting = false,
|
||||||
onOpenTerminal,
|
onOpenTerminal,
|
||||||
stopped = 0,
|
stopped = 0,
|
||||||
|
sessionId,
|
||||||
}: MessageListProps) {
|
}: MessageListProps) {
|
||||||
const { t } = useTranslation("sessions");
|
const { t } = useTranslation("sessions");
|
||||||
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
||||||
@@ -659,6 +671,10 @@ function MessageListView({
|
|||||||
);
|
);
|
||||||
const stats = useMemo(() => (pending ? turnStats(messages) : null), [pending, messages]);
|
const stats = useMemo(() => (pending ? turnStats(messages) : null), [pending, messages]);
|
||||||
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
|
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
|
||||||
|
const agents = useMemo(
|
||||||
|
() => (live ? backgroundAgents(messages, toolResultMap) : []),
|
||||||
|
[live, messages, toolResultMap]
|
||||||
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -715,7 +731,14 @@ function MessageListView({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "done":
|
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 && (
|
{pending && (
|
||||||
@@ -727,6 +750,7 @@ function MessageListView({
|
|||||||
onOpenTerminal={onOpenTerminal}
|
onOpenTerminal={onOpenTerminal}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{agents.length > 0 && <BackgroundAgents agents={agents} sessionId={sessionId} />}
|
||||||
</div>
|
</div>
|
||||||
</OpenFileContext.Provider>
|
</OpenFileContext.Provider>
|
||||||
);
|
);
|
||||||
@@ -783,6 +807,48 @@ export function workingOn(
|
|||||||
return alive ? { blocks, since: null, last } : null;
|
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
|
/** Output tokens counted once per API call: Claude Code writes one line per
|
||||||
* content block, each repeating its call's usage — consecutive lines with
|
* content block, each repeating its call's usage — consecutive lines with
|
||||||
* the same prompt size are one call. */
|
* the same prompt size are one call. */
|
||||||
@@ -857,6 +923,23 @@ function useCountUp(value: number): number {
|
|||||||
return shown;
|
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
|
/** A call's one-line title: its description (Claude Code's Bash
|
||||||
* `description`), else the tool and its target. */
|
* `description`), else the tool and its target. */
|
||||||
function stepTitle(block: TranscriptContent): string {
|
function stepTitle(block: TranscriptContent): string {
|
||||||
@@ -886,20 +969,9 @@ function RunningStep({
|
|||||||
onOpenTerminal?: () => void;
|
onOpenTerminal?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("sessions");
|
const { t } = useTranslation("sessions");
|
||||||
const [now, setNow] = useState(() => Date.now());
|
|
||||||
const [showAll, setShowAll] = useState(false);
|
const [showAll, setShowAll] = useState(false);
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
const now = useClock(boxRef);
|
||||||
// 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 counted = useCountUp(tokens);
|
const counted = useCountUp(tokens);
|
||||||
const age = (at: string | null) => (at ? now - parseDate(at).getTime() : 0);
|
const age = (at: string | null) => (at ? now - parseDate(at).getTime() : 0);
|
||||||
const first = blocks[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
|
/** 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
|
* answer the CLI records; the user's pick reads without it (every picked
|
||||||
* label of a multi-select answer too). */
|
* 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");
|
const { t } = useTranslation("sessions");
|
||||||
return (
|
return (
|
||||||
<div data-testid="chat-turn-done" className="flex items-center gap-1.5 text-xs text-fg-muted">
|
<div data-testid="chat-turn-done" className="flex items-center gap-1.5 text-xs text-fg-muted">
|
||||||
|
{agents > 0 ? (
|
||||||
|
<Loader2 className="h-3 w-3 flex-shrink-0 animate-spin text-accent" />
|
||||||
|
) : (
|
||||||
<Check className="h-3 w-3 flex-shrink-0" />
|
<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) }),
|
output > 0 && t("conversation.turnTokens", { tokens: fmt(output) }),
|
||||||
|
agents > 0 && t("conversation.agentsStill", { count: agents }),
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ")}
|
.join(" · ")}
|
||||||
@@ -1045,8 +1276,8 @@ function TurnDone({ ms, output }: { ms: number; output: number }) {
|
|||||||
|
|
||||||
const meta = "text-[10px] font-mono text-fg-muted";
|
const meta = "text-[10px] font-mono text-fg-muted";
|
||||||
|
|
||||||
// What the prompt box sends for an attached file: its saved path under the
|
// What the prompt box sends for an attached file: its saved path (an `@`
|
||||||
// drop dir (`<data dir>/drops/<16 hex>[-name].ext`, server/routes/run.js; the
|
// 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).
|
// older `<tmp>/agenthub-drops-<uid>/` still in past transcripts).
|
||||||
const DROP_PATH =
|
const DROP_PATH =
|
||||||
/(?<=^|\s)(\S*[\\/](?:agenthub-drops--?\d+|drops)[\\/]([0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+))(?=\s|$)/gi;
|
/(?<=^|\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 files: string[] = [];
|
||||||
const rest = text.replace(DROP_PATH, (_all, full: string, name: string) => {
|
const rest = text.replace(DROP_PATH, (_all, full: string, name: string) => {
|
||||||
if (IMAGE_EXT.test(name)) images.push(name);
|
if (IMAGE_EXT.test(name)) images.push(name);
|
||||||
else files.push(full);
|
else files.push(full.replace(/^@/, "")); // a claude prompt's `@` mention
|
||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
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
|
* layout), Main agent / System under their own label — instead of treating
|
||||||
* every `type:"user"` line as the user (reported transcript mis-attribution) —
|
* 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`
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
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";
|
import type { TranscriptMessage } from "../../../lib/types";
|
||||||
|
|
||||||
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||||
@@ -343,4 +344,77 @@ describe("MessageList — sender attribution", () => {
|
|||||||
// Silent for 15 minutes with nothing running: the turn died.
|
// Silent for 15 minutes with nothing running: the turn died.
|
||||||
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
|
* 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/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
|
* 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
|
* default AI tool (Settings → AI Tools), and each new session picks its own
|
||||||
* AI on the workspace home. Either
|
* AI on the workspace home. Either
|
||||||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
* 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 { CwdAutocomplete } from "../run/RunSetup";
|
||||||
import { PathPickButton } from "../PathPickButton";
|
import { PathPickButton } from "../PathPickButton";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||||
import type { CwdSuggestion } from "../../lib/api";
|
import type { CwdSuggestion } from "../../lib/api";
|
||||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||||
import { useAiTools } from "../../hooks/useAiTools";
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
@@ -116,6 +118,9 @@ export function AddLaneModal({
|
|||||||
// server asked for one — a login for the URL's https host.
|
// server asked for one — a login for the URL's https host.
|
||||||
const [cloneUrl, setCloneUrl] = useState("");
|
const [cloneUrl, setCloneUrl] = useState("");
|
||||||
const [cloneName, setCloneName] = 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<{
|
const [login, setLogin] = useState<{
|
||||||
host: string;
|
host: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -159,6 +164,7 @@ export function AddLaneModal({
|
|||||||
setMode("repo");
|
setMode("repo");
|
||||||
setCloneUrl("");
|
setCloneUrl("");
|
||||||
setCloneName("");
|
setCloneName("");
|
||||||
|
setClonePartial(false);
|
||||||
setLogin(null);
|
setLogin(null);
|
||||||
setSshRefused(false);
|
setSshRefused(false);
|
||||||
setSourceRepo("");
|
setSourceRepo("");
|
||||||
@@ -236,13 +242,17 @@ export function AddLaneModal({
|
|||||||
const url = cloneUrl.trim();
|
const url = cloneUrl.trim();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
setSshRefused(false);
|
setSshRefused(false);
|
||||||
|
const progressToken = newCloneToken();
|
||||||
|
setCloneToken(progressToken);
|
||||||
try {
|
try {
|
||||||
const result = await api.lanes.clone({
|
const result = await api.lanes.clone({
|
||||||
|
progressToken,
|
||||||
url,
|
url,
|
||||||
parent: repo,
|
parent: repo,
|
||||||
name: cloneName.trim() || undefined,
|
name: cloneName.trim() || undefined,
|
||||||
title: name || undefined,
|
title: name || undefined,
|
||||||
tool,
|
tool,
|
||||||
|
...(clonePartial ? { partial: true } : {}),
|
||||||
...(login?.username.trim() && login.token
|
...(login?.username.trim() && login.token
|
||||||
? {
|
? {
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -274,6 +284,8 @@ export function AddLaneModal({
|
|||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
} finally {
|
||||||
|
setCloneToken(null);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -547,6 +559,23 @@ export function AddLaneModal({
|
|||||||
spellCheck={false}
|
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"
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* @file CloneHereDialog.tsx
|
||||||
|
* @description The Files page's **New → Clone repository**: clones a URL into
|
||||||
|
* the folder the File Explorer has open, through the same
|
||||||
|
* `POST /api/lanes/clone` as Add workspace → Clone — the server holds the URL
|
||||||
|
* to its allowlist and the folder to the user's own, and clones as the
|
||||||
|
* folder's account with that account's own SSH key / HTTPS login — and opens
|
||||||
|
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
|
||||||
|
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FolderGit2 } from "lucide-react";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import type { Lane } from "../../lib/types";
|
||||||
|
import { useDialog } from "../../hooks/useDialog";
|
||||||
|
import { cloneDirName } from "./AddLaneModal";
|
||||||
|
|
||||||
|
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||||
|
|
||||||
|
export function CloneHereDialog({
|
||||||
|
open,
|
||||||
|
parent,
|
||||||
|
onClose,
|
||||||
|
onCloned,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
/** The absolute folder the clone goes into. */
|
||||||
|
parent: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onCloned: (lane: Lane, path: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(["lanes"]);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [login, setLogin] = useState<Login | null>(null);
|
||||||
|
const [sshRefused, setSshRefused] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
useDialog(open, panelRef, busy ? () => {} : onClose);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setUrl("");
|
||||||
|
setName("");
|
||||||
|
setLogin(null);
|
||||||
|
setSshRefused(false);
|
||||||
|
setBusy(false);
|
||||||
|
setError(null);
|
||||||
|
}, [open, parent]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
const folder = name.trim() || cloneDirName(url);
|
||||||
|
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const u = url.trim();
|
||||||
|
if (!u || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setSshRefused(false);
|
||||||
|
try {
|
||||||
|
const result = await api.lanes.clone({
|
||||||
|
url: u,
|
||||||
|
parent,
|
||||||
|
name: name.trim() || undefined,
|
||||||
|
...(login?.username.trim() && login.token
|
||||||
|
? {
|
||||||
|
credentials: {
|
||||||
|
username: login.username.trim(),
|
||||||
|
password: login.token,
|
||||||
|
remember: login.remember,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
onCloned(result.lane, result.path);
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||||
|
const host = e.auth?.host || u;
|
||||||
|
if (e.code === "ECREDENTIALS") {
|
||||||
|
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
|
||||||
|
setLogin((l) => ({
|
||||||
|
host,
|
||||||
|
username: l?.username ?? "",
|
||||||
|
token: "",
|
||||||
|
remember: l?.remember ?? true,
|
||||||
|
}));
|
||||||
|
} else if (e.code === "ESSHAUTH") {
|
||||||
|
setSshRefused(true);
|
||||||
|
setError(t("clone.sshRefused", { host }));
|
||||||
|
} else {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||||
|
onClick={busy ? undefined : onClose}
|
||||||
|
// A portal still bubbles through React: keep keys away from the File
|
||||||
|
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
data-testid="fx-clone-dialog"
|
||||||
|
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 id={titleId} className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||||
|
<FolderGit2 className="h-4 w-4" />
|
||||||
|
{t("fx.cloneRepo")}
|
||||||
|
</h2>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={`${titleId}-url`}>
|
||||||
|
{t("clone.urlLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${titleId}-url`}
|
||||||
|
autoFocus
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="https://host/owner/repo.git"
|
||||||
|
disabled={busy}
|
||||||
|
className="input w-full font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[11px] text-fg-muted">{t("clone.urlHint")}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={`${titleId}-name`}>
|
||||||
|
{t("clone.nameLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${titleId}-name`}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={cloneDirName(url)}
|
||||||
|
disabled={busy}
|
||||||
|
className="input w-full font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 break-all text-[11px] text-fg-muted">
|
||||||
|
{t("clone.parentHint", { path: into })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{login && (
|
||||||
|
<div data-testid="fx-clone-login" className="space-y-2 rounded-md border border-border p-2">
|
||||||
|
<input
|
||||||
|
aria-label={t("clone.username")}
|
||||||
|
placeholder={t("clone.username")}
|
||||||
|
autoComplete="username"
|
||||||
|
value={login.username}
|
||||||
|
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||||
|
disabled={busy}
|
||||||
|
className="input w-full text-xs"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label={t("clone.token")}
|
||||||
|
placeholder={t("clone.token")}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={login.token}
|
||||||
|
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||||||
|
disabled={busy}
|
||||||
|
className="input w-full text-xs"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-fg-secondary">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={login.remember}
|
||||||
|
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
{t("clone.remember")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-xs text-status-danger">
|
||||||
|
{error}{" "}
|
||||||
|
{sshRefused && (
|
||||||
|
<a href="/settings/git#git-identity" className="underline">
|
||||||
|
{t("clone.sshSettings")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button type="button" onClick={onClose} disabled={busy} className="btn-ghost text-xs">
|
||||||
|
{t("fx.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || !url.trim()}
|
||||||
|
className="btn-primary text-xs"
|
||||||
|
data-testid="fx-clone-submit"
|
||||||
|
>
|
||||||
|
{busy ? t("clone.cloning") : t("fx.cloneSubmit")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,18 +4,62 @@
|
|||||||
* (`POST /api/lanes/:id/sources/clone`, the server picks the folder) and
|
* (`POST /api/lanes/:id/sources/clone`, the server picks the folder) and
|
||||||
* added as an extra folder, one after another. A host that wants a login
|
* added as an extra folder, one after another. A host that wants a login
|
||||||
* (409 `ECREDENTIALS`) opens one login row; Retry re-runs only the failed
|
* (409 `ECREDENTIALS`) opens one login row; Retry re-runs only the failed
|
||||||
* URLs with it, like Add workspace → Clone.
|
* URLs with it, like Add workspace → Clone. Before anything runs, each URL
|
||||||
|
* is checked against the user's own git identity (`GET /api/me/git`): an SSH
|
||||||
|
* link while the account has no SSH key is not cloned at all — the user is
|
||||||
|
* 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. 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Check, Loader2, X } from "lucide-react";
|
import { Check, Loader2, X } from "lucide-react";
|
||||||
import { api, type LaneSourcesView } from "../../lib/api";
|
import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api";
|
||||||
|
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||||
|
|
||||||
type Row = { url: string; state: "wait" | "busy" | "done" | "failed"; note?: string };
|
type Row = {
|
||||||
|
url: string;
|
||||||
|
state: "wait" | "busy" | "done" | "failed";
|
||||||
|
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 };
|
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||||
|
|
||||||
|
/** How a clone URL reaches its host: `ssh://…` or scp-like `user@host:path`, else HTTPS. */
|
||||||
|
export function cloneTarget(url: string): { kind: "ssh" | "https"; host: string } | null {
|
||||||
|
const scp = /^(?:[^@\s/]+@)?([^:/\s]+):(?!\/\/)/.exec(url);
|
||||||
|
if (scp?.[1]) return { kind: "ssh", host: scp[1] };
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (u.protocol === "ssh:") return { kind: "ssh", host: u.hostname };
|
||||||
|
if (u.protocol === "https:" || u.protocol === "http:") return { kind: "https", host: u.host };
|
||||||
|
} catch {
|
||||||
|
/* not a URL yet — the server's allowlist answers that one */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the identity lacks for one URL: `ssh` blocks the clone, `https` only asks. */
|
||||||
|
function missingFor(
|
||||||
|
url: string,
|
||||||
|
id: GitIdentity | null
|
||||||
|
): { kind: "ssh" | "https"; host: string } | null {
|
||||||
|
const target = cloneTarget(url);
|
||||||
|
if (!id || !target) return null;
|
||||||
|
if (target.kind === "ssh") {
|
||||||
|
const keys = id.sshKeys?.length ?? (id.ssh ? 1 : 0);
|
||||||
|
return keys > 0 ? null : target;
|
||||||
|
}
|
||||||
|
return id.https.some((h) => h.host === target.host) ? null : target;
|
||||||
|
}
|
||||||
|
|
||||||
export function CloneSourcesForm({
|
export function CloneSourcesForm({
|
||||||
laneId,
|
laneId,
|
||||||
parent,
|
parent,
|
||||||
@@ -31,6 +75,25 @@ export function CloneSourcesForm({
|
|||||||
const [rows, setRows] = useState<Row[]>([]);
|
const [rows, setRows] = useState<Row[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [login, setLogin] = useState<Login | null>(null);
|
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
|
||||||
|
// server's own ECREDENTIALS / ESSHAUTH answers still cover it.
|
||||||
|
const loadIdentity = useCallback(
|
||||||
|
() =>
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => api.gitIdentity.get())
|
||||||
|
.then(setIdentity)
|
||||||
|
.catch(() => {}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
void loadIdentity();
|
||||||
|
const onFocus = () => void loadIdentity();
|
||||||
|
window.addEventListener("focus", onFocus);
|
||||||
|
return () => window.removeEventListener("focus", onFocus);
|
||||||
|
}, [loadIdentity]);
|
||||||
|
|
||||||
const urls = (s: string) => [
|
const urls = (s: string) => [
|
||||||
...new Set(
|
...new Set(
|
||||||
@@ -44,16 +107,27 @@ export function CloneSourcesForm({
|
|||||||
const cloneAll = async (list: string[]) => {
|
const cloneAll = async (list: string[]) => {
|
||||||
if (busy || !list.length) return;
|
if (busy || !list.length) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const next: Row[] = list.map((url) => ({ url, state: "wait" }));
|
// An SSH link with no key on the account would only fail at the host:
|
||||||
|
// it is not tried, and its row says what to set up first.
|
||||||
|
const next: Row[] = list.map((url) => {
|
||||||
|
const miss = missingFor(url, identity);
|
||||||
|
return miss?.kind === "ssh"
|
||||||
|
? { url, state: "failed", note: t("clone.sshNoKey", { host: miss.host }), ssh: true }
|
||||||
|
: { url, state: "wait" };
|
||||||
|
});
|
||||||
const show = () => setRows([...next]);
|
const show = () => setRows([...next]);
|
||||||
show();
|
show();
|
||||||
let asked: Login | null = null;
|
let asked: Login | null = null;
|
||||||
for (const row of next) {
|
for (const row of next) {
|
||||||
|
if (row.state === "failed") continue;
|
||||||
row.state = "busy";
|
row.state = "busy";
|
||||||
|
row.token = newCloneToken();
|
||||||
show();
|
show();
|
||||||
try {
|
try {
|
||||||
const view = await api.lanes.cloneFolder(laneId, {
|
const view = await api.lanes.cloneFolder(laneId, {
|
||||||
url: row.url,
|
url: row.url,
|
||||||
|
progressToken: row.token,
|
||||||
|
...(partial ? { partial: true } : {}),
|
||||||
...(login?.username.trim() && login.token
|
...(login?.username.trim() && login.token
|
||||||
? {
|
? {
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -80,10 +154,12 @@ export function CloneSourcesForm({
|
|||||||
};
|
};
|
||||||
} else if (e.code === "ESSHAUTH") {
|
} else if (e.code === "ESSHAUTH") {
|
||||||
row.note = t("clone.sshRefused", { host });
|
row.note = t("clone.sshRefused", { host });
|
||||||
|
row.ssh = true;
|
||||||
} else {
|
} else {
|
||||||
row.note = e.message || String(err);
|
row.note = e.message || String(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
row.token = undefined;
|
||||||
show();
|
show();
|
||||||
}
|
}
|
||||||
if (asked) setLogin(asked);
|
if (asked) setLogin(asked);
|
||||||
@@ -99,6 +175,18 @@ export function CloneSourcesForm({
|
|||||||
|
|
||||||
const failed = rows.filter((r) => r.state === "failed").map((r) => r.url);
|
const failed = rows.filter((r) => r.state === "failed").map((r) => r.url);
|
||||||
|
|
||||||
|
// Checked as the user types, so a missing key is said before Clone is pressed.
|
||||||
|
const missing = urls(text)
|
||||||
|
.map((u) => missingFor(u, identity))
|
||||||
|
.filter((m): m is NonNullable<typeof m> => m !== null);
|
||||||
|
const sshHosts = [...new Set(missing.filter((m) => m.kind === "ssh").map((m) => m.host))];
|
||||||
|
const httpsHosts = [...new Set(missing.filter((m) => m.kind === "https").map((m) => m.host))];
|
||||||
|
const settingsLink = (
|
||||||
|
<a href="/settings/git#git-identity" target="_blank" rel="noreferrer" className="underline">
|
||||||
|
{t("clone.sshSettings")}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 border-t border-border-light pt-2" data-testid="workspace-clone">
|
<div className="space-y-2 border-t border-border-light pt-2" data-testid="workspace-clone">
|
||||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{t("home.cloneFolders")}</p>
|
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{t("home.cloneFolders")}</p>
|
||||||
@@ -112,6 +200,41 @@ export function CloneSourcesForm({
|
|||||||
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
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>
|
<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"
|
||||||
|
data-testid="workspace-clone-need-ssh"
|
||||||
|
className="text-[11px] text-status-danger"
|
||||||
|
>
|
||||||
|
{t("home.cloneNeedSsh", { hosts: sshHosts.join(", ") })} {settingsLink}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{httpsHosts.length > 0 && !login && (
|
||||||
|
<p data-testid="workspace-clone-need-login" className="text-[11px] text-status-warning">
|
||||||
|
{t("home.cloneNeedLogin", { hosts: httpsHosts.join(", ") })}{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="underline"
|
||||||
|
onClick={() =>
|
||||||
|
setLogin({ host: httpsHosts[0] ?? "", username: "", token: "", remember: true })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("home.cloneEnterLogin")}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{login && (
|
{login && (
|
||||||
<div className="space-y-1.5 rounded border border-border-light p-2">
|
<div className="space-y-1.5 rounded border border-border-light p-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -178,7 +301,17 @@ export function CloneSourcesForm({
|
|||||||
{r.url}
|
{r.url}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{r.note && <span className="ml-[18px] block text-status-danger">{r.note}</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}
|
||||||
|
{r.ssh && <> {settingsLink}</>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ import { confirmDialog } from "../../lib/confirm";
|
|||||||
import { formatBytes } from "../../lib/format";
|
import { formatBytes } from "../../lib/format";
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
|
import { CloneHereDialog } from "./CloneHereDialog";
|
||||||
|
|
||||||
/** One upload batch at most (the server caps one file the same). */
|
/** One upload batch at most (the server caps one file the same). */
|
||||||
export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024;
|
export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024;
|
||||||
@@ -268,6 +269,7 @@ export function FileBrowser({
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null);
|
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null);
|
||||||
const [creating, setCreating] = useState<{ kind: "dir" | "file"; value: string } | null>(null);
|
const [creating, setCreating] = useState<{ kind: "dir" | "file"; value: string } | null>(null);
|
||||||
|
const [cloning, setCloning] = useState(false);
|
||||||
const [ask, setAsk] = useState<Ask | null>(null);
|
const [ask, setAsk] = useState<Ask | null>(null);
|
||||||
const [askAll, setAskAll] = useState(false);
|
const [askAll, setAskAll] = useState(false);
|
||||||
const [progress, setProgress] = useState<{
|
const [progress, setProgress] = useState<{
|
||||||
@@ -314,6 +316,18 @@ export function FileBrowser({
|
|||||||
}, []);
|
}, []);
|
||||||
// Another user's home (an admin's view) is read-only.
|
// Another user's home (an admin's view) is read-only.
|
||||||
const ro = isHomePlace(target) && !!homes.find((h) => h.id === target.home)?.readOnly;
|
const ro = isHomePlace(target) && !!homes.find((h) => h.id === target.home)?.readOnly;
|
||||||
|
// Clone into the open folder: the absolute path of a workspace's own folder
|
||||||
|
// or of the user's own home (never a session worktree, an extra folder or
|
||||||
|
// another user's home); the server checks the folder again.
|
||||||
|
const cloneRoot = isHomePlace(target)
|
||||||
|
? target.home === "me"
|
||||||
|
? (homes.find((h) => h.id === "me")?.dir ?? null)
|
||||||
|
: null
|
||||||
|
: typeof target === "number"
|
||||||
|
? (lanes.find((l) => l.id === target)?.cwd ?? null)
|
||||||
|
: null;
|
||||||
|
const cloneParent =
|
||||||
|
cloneRoot && view === "files" ? (path ? `${cloneRoot.replace(/\/+$/, "")}/${path}` : cloneRoot) : null;
|
||||||
|
|
||||||
/** Go to `p` in workspace `tgt`, remembering where we were for Back. */
|
/** Go to `p` in workspace `tgt`, remembering where we were for Back. */
|
||||||
const go = (p: string, tgt: FsPlace = target) => {
|
const go = (p: string, tgt: FsPlace = target) => {
|
||||||
@@ -633,7 +647,7 @@ export function FileBrowser({
|
|||||||
writePref(VIEW_KEY, l);
|
writePref(VIEW_KEY, l);
|
||||||
};
|
};
|
||||||
const onKey = (e: ReactKeyboardEvent) => {
|
const onKey = (e: ReactKeyboardEvent) => {
|
||||||
if (renaming || creating || ask) return;
|
if (renaming || creating || ask || cloning) return;
|
||||||
const mod = e.ctrlKey || e.metaKey;
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
const k = e.key.toLowerCase();
|
const k = e.key.toLowerCase();
|
||||||
// Another user's home is read-only: no key that writes.
|
// Another user's home is read-only: no key that writes.
|
||||||
@@ -784,6 +798,19 @@ export function FileBrowser({
|
|||||||
onClick={() => setMenu(null)}
|
onClick={() => setMenu(null)}
|
||||||
data-testid="file-explorer"
|
data-testid="file-explorer"
|
||||||
>
|
>
|
||||||
|
{cloneParent && (
|
||||||
|
<CloneHereDialog
|
||||||
|
open={cloning}
|
||||||
|
parent={cloneParent}
|
||||||
|
onClose={() => setCloning(false)}
|
||||||
|
onCloned={(lane, at) => {
|
||||||
|
setCloning(false);
|
||||||
|
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
|
||||||
|
setLanes((ls) => (ls.some((l) => l.id === lane.id) ? ls : [...ls, lane]));
|
||||||
|
changed();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* ── Places ── */}
|
{/* ── Places ── */}
|
||||||
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
|
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
|
||||||
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label={t("fx.places")}>
|
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label={t("fx.places")}>
|
||||||
@@ -1018,6 +1045,17 @@ export function FileBrowser({
|
|||||||
>
|
>
|
||||||
{t("fx.newFile")}
|
{t("fx.newFile")}
|
||||||
</DropItem>
|
</DropItem>
|
||||||
|
{cloneParent && (
|
||||||
|
<DropItem
|
||||||
|
icon={<FolderGit2 className="h-4 w-4" />}
|
||||||
|
onClick={() => {
|
||||||
|
setMenu(null);
|
||||||
|
setCloning(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("fx.cloneRepo")}
|
||||||
|
</DropItem>
|
||||||
|
)}
|
||||||
</DropButton>
|
</DropButton>
|
||||||
<DropButton
|
<DropButton
|
||||||
label={t("fx.upload")}
|
label={t("fx.upload")}
|
||||||
@@ -1449,6 +1487,9 @@ export function FileBrowser({
|
|||||||
{t("fx.newFolder")}
|
{t("fx.newFolder")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onSelect={close(() => startCreate("file"))}>{t("fx.newFile")}</MenuItem>
|
<MenuItem onSelect={close(() => startCreate("file"))}>{t("fx.newFile")}</MenuItem>
|
||||||
|
{cloneParent && (
|
||||||
|
<MenuItem onSelect={close(() => setCloning(true))}>{t("fx.cloneRepo")}</MenuItem>
|
||||||
|
)}
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
<MenuItem onSelect={close(() => fileInput.current?.click())}>
|
<MenuItem onSelect={close(() => fileInput.current?.click())}>
|
||||||
{t("fx.uploadFiles")}
|
{t("fx.uploadFiles")}
|
||||||
|
|||||||
@@ -1728,7 +1728,12 @@ export function LaneChangesPanel({
|
|||||||
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
|
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
danger
|
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"
|
testId="lane-changes-menu-undo-commit"
|
||||||
onSelect={pick(
|
onSelect={pick(
|
||||||
commitCtx.close,
|
commitCtx.close,
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
* first, and a session some tab already holds opens THAT tab instead of a
|
* first, and a session some tab already holds opens THAT tab instead of a
|
||||||
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
|
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
|
||||||
* message is pasted there (never sent: Enter is the user's), with a toast; while
|
* message is pasted there (never sent: Enter is the user's), with a toast; while
|
||||||
* it is live the composer is off. Only Claude and Codex can resume a session by id —
|
* it is live the composer is off. Only Claude, Codex and a catalog tool with
|
||||||
* any other tool's session offers a new session from the workspace home
|
* `resumeById` (omp) can resume a session by id — any other tool's session
|
||||||
|
* offers a new session from the workspace home
|
||||||
* instead. The header carries the transcript page's Resume/Fork
|
* instead. The header carries the transcript page's Resume/Fork
|
||||||
* (`SessionResumeButton`, behind its warning). The session's tool and folder
|
* (`SessionResumeButton`, behind its warning). The session's tool and folder
|
||||||
* come from its row (`GET /api/sessions/:id`), so a resume runs on the tool the
|
* come from its row (`GET /api/sessions/:id`), so a resume runs on the tool the
|
||||||
@@ -32,6 +33,7 @@ import { toast } from "../../lib/toast";
|
|||||||
import i18n from "../../i18n";
|
import i18n from "../../i18n";
|
||||||
import { liveRunFor } from "./WorkspaceSessionList";
|
import { liveRunFor } from "./WorkspaceSessionList";
|
||||||
import { SessionResumeButton } from "./SessionResumeButton";
|
import { SessionResumeButton } from "./SessionResumeButton";
|
||||||
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
|
|
||||||
/** A message for a session that a tab already holds goes into THAT tab as a
|
/** A message for a session that a tab already holds goes into THAT tab as a
|
||||||
* paste, once its terminal is on screen (it mounts after the navigation);
|
* paste, once its terminal is on screen (it mounts after the navigation);
|
||||||
@@ -47,6 +49,7 @@ function pasteWhenShown(runId: string, text: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The tools whose launcher resumes a session BY ID (server/lib/pty-run.js). */
|
/** The tools whose launcher resumes a session BY ID (server/lib/pty-run.js). */
|
||||||
|
// Before the catalog loads: the two hand-written launchers resume by id.
|
||||||
const RESUMABLE = new Set(["claude", "codex"]);
|
const RESUMABLE = new Set(["claude", "codex"]);
|
||||||
|
|
||||||
export function SessionReplay({
|
export function SessionReplay({
|
||||||
@@ -65,6 +68,7 @@ export function SessionReplay({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { byId } = useAiTools();
|
||||||
const [session, setSession] = useState<Session | null>(null);
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [images, setImages] = useState<PromptImage[]>([]);
|
const [images, setImages] = useState<PromptImage[]>([]);
|
||||||
@@ -88,9 +92,9 @@ export function SessionReplay({
|
|||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
const tool = session?.tool || "claude";
|
const tool = session?.tool || "claude";
|
||||||
const resumable = RESUMABLE.has(tool);
|
const resumable = byId(tool)?.resumeById ?? RESUMABLE.has(tool);
|
||||||
const livePane = liveRunFor(lane, liveRuns, sessionId);
|
const livePane = liveRunFor(lane, liveRuns, sessionId);
|
||||||
const text = promptWithImages(prompt, images);
|
const text = promptWithImages(prompt, images, tool === "claude");
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
if (!text || busy || uploading || livePane || !resumable) return;
|
if (!text || busy || uploading || livePane || !resumable) return;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file SessionResumeButton.tsx
|
* @file SessionResumeButton.tsx
|
||||||
* @description The transcript page's explicit Resume and Fork for a Claude
|
* @description The transcript page's explicit Resume and Fork for a Claude
|
||||||
* session inside a workspace. Opening a session never resumes it; this is the
|
* session inside a workspace (Resume only for a catalog tool that resumes by
|
||||||
|
* id — omp's `--resume <id>`). Opening a session never resumes it; this is the
|
||||||
* one place that does, behind a warning, because a resume re-reads the whole
|
* one place that does, behind a warning, because a resume re-reads the whole
|
||||||
* transcript into context. Fork resumes it into a NEW session id
|
* transcript into context. Fork resumes it into a NEW session id
|
||||||
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
|
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
|
||||||
@@ -26,6 +27,7 @@ import { ConfirmModal } from "../ConfirmModal";
|
|||||||
import { startInFreeTabDetailed } from "./startSession";
|
import { startInFreeTabDetailed } from "./startSession";
|
||||||
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
||||||
import { usePermissions } from "../AuthGate";
|
import { usePermissions } from "../AuthGate";
|
||||||
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
|
|
||||||
/** A path without its trailing separators — `/` or a Windows `\`. */
|
/** A path without its trailing separators — `/` or a Windows `\`. */
|
||||||
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
||||||
@@ -54,6 +56,7 @@ export function SessionResumeButton({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { lanes } = useLanes();
|
const { lanes } = useLanes();
|
||||||
const { canEditLane } = usePermissions();
|
const { canEditLane } = usePermissions();
|
||||||
|
const { byId } = useAiTools();
|
||||||
const [runs, setRuns] = useState<RunHandle[] | null>(null);
|
const [runs, setRuns] = useState<RunHandle[] | null>(null);
|
||||||
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
|
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -70,12 +73,13 @@ export function SessionResumeButton({
|
|||||||
};
|
};
|
||||||
}, [session.id]);
|
}, [session.id]);
|
||||||
|
|
||||||
// Claude only: a Codex session id is not what
|
// Claude, or a catalog tool that resumes by id (no launcher of its own): a
|
||||||
// `codex resume` takes for every kind of thread.
|
// Codex session id is not what `codex resume` takes for every kind of thread.
|
||||||
const tool = session.tool || "claude";
|
const tool = session.tool || "claude";
|
||||||
|
const catalogResume = !!byId(tool)?.resumeById && !byId(tool)?.launcher;
|
||||||
// The workspace that owns it, else the one whose folder holds it.
|
// The workspace that owns it, else the one whose folder holds it.
|
||||||
const lane =
|
const lane =
|
||||||
tool !== "claude" || !session.cwd
|
(tool !== "claude" && !catalogResume) || !session.cwd
|
||||||
? null
|
? null
|
||||||
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
|
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
|
||||||
owningLane(session.cwd, tool, lanes));
|
owningLane(session.cwd, tool, lanes));
|
||||||
@@ -140,7 +144,7 @@ export function SessionResumeButton({
|
|||||||
{t("sessions.resume")}
|
{t("sessions.resume")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{resumable && (
|
{resumable && tool === "claude" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirming("fork")}
|
onClick={() => setConfirming("fork")}
|
||||||
|
|||||||
@@ -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
|
* 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
|
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
|
||||||
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
|
* (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,
|
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
||||||
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
||||||
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
|
* its right-click menu), 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Checkbox } from "../Checkbox";
|
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
@@ -34,6 +33,7 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
@@ -76,7 +76,7 @@ export {
|
|||||||
} from "./startSession";
|
} from "./startSession";
|
||||||
import { startInFreeTab } from "./startSession";
|
import { startInFreeTab } from "./startSession";
|
||||||
import { WorkspaceContext } from "./WorkspaceContext";
|
import { WorkspaceContext } from "./WorkspaceContext";
|
||||||
import { readDefaultMode } from "../../lib/sessionDefaults";
|
import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
|
||||||
|
|
||||||
/** Recents' page size; Show more adds another. */
|
/** Recents' page size; Show more adds another. */
|
||||||
const RECENT_PAGE = 30;
|
const RECENT_PAGE = 30;
|
||||||
@@ -165,12 +165,14 @@ function HomeBody({
|
|||||||
// Claude and Codex always take a model and an effort; a catalog tool a
|
// 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.
|
// model only when its file names the flag, an effort never.
|
||||||
const hasModel = claude || codex || !!entry?.modelFlag;
|
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 [mode, setMode] = useState<PermissionMode>(readDefaultMode);
|
||||||
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
|
const [sandbox, setSandbox] = useState<CodexSandbox>(() => readCodexDefaults().sandbox);
|
||||||
const [approval, setApproval] = useState<CodexApproval>("on-request");
|
const [approval, setApproval] = useState<CodexApproval>(() => readCodexDefaults().approval);
|
||||||
// Per session, never remembered.
|
// Off for every tool but Codex, which starts from the account's default.
|
||||||
const [bypass, setBypass] = useState(false);
|
const [bypass, setBypass] = useState(false);
|
||||||
|
useEffect(() => setBypass(codex && readCodexDefaults().bypass), [codex]);
|
||||||
const canBypass = !claude && !!entry?.bypass;
|
const canBypass = !claude && !!entry?.bypass;
|
||||||
const [effort, setEffort] = useState<EffortLevel>("");
|
const [effort, setEffort] = useState<EffortLevel>("");
|
||||||
const hasEffort = claude || codex;
|
const hasEffort = claude || codex;
|
||||||
@@ -254,7 +256,7 @@ function HomeBody({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const text = promptWithImages(prompt, images);
|
const text = promptWithImages(prompt, images, claude);
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
// Enter reaches here past the disabled button: the same guards again.
|
// 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.
|
// A ref, not only the state: two Enters in one tick both read `starting` false.
|
||||||
@@ -607,15 +609,25 @@ function HomeBody({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{canBypass && (
|
{canBypass && (
|
||||||
<span className="px-2">
|
// A chip like the selects beside it: quiet when off, red when on.
|
||||||
<Checkbox
|
<button
|
||||||
tone="danger"
|
type="button"
|
||||||
checked={bypass}
|
aria-pressed={bypass}
|
||||||
onChange={setBypass}
|
data-testid="workspace-home-bypass"
|
||||||
label={t("run:fields.bypass")}
|
title={t(codex ? "run:fields.yoloHint" : "run:fields.bypass")}
|
||||||
labelClassName="text-xs text-status-danger"
|
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>
|
</div>
|
||||||
{danger && (
|
{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].credentials).toBeUndefined();
|
||||||
|
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].partial).toBeUndefined();
|
||||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
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 () => {
|
it("asks for a login on ECREDENTIALS and sends it on the retry", async () => {
|
||||||
vi.mocked(api.lanes.clone)
|
vi.mocked(api.lanes.clone)
|
||||||
.mockRejectedValueOnce(
|
.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* @file Tests for CloneSourcesForm: every URL in the box is cloned in turn as
|
* @file Tests for CloneSourcesForm: every URL in the box is cloned in turn as
|
||||||
* an extra folder, a host that wants a login opens the login row, and Retry
|
* an extra folder, a host that wants a login opens the login row, and Retry
|
||||||
* re-runs only the failed URLs with that login.
|
* 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. The
|
||||||
|
* partial-clone box adds `partial: true` to every clone.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -11,16 +14,20 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import "../../../i18n";
|
import "../../../i18n";
|
||||||
|
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
api: { lanes: { cloneFolder: vi.fn() } },
|
api: { lanes: { cloneFolder: vi.fn() }, gitIdentity: { get: vi.fn() } },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { api, type LaneSourcesView } from "../../../lib/api";
|
import { api, type GitIdentity, type LaneSourcesView } from "../../../lib/api";
|
||||||
import { CloneSourcesForm } from "../CloneSourcesForm";
|
import { CloneSourcesForm, cloneTarget } from "../CloneSourcesForm";
|
||||||
|
|
||||||
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
||||||
|
|
||||||
describe("CloneSourcesForm", () => {
|
describe("CloneSourcesForm", () => {
|
||||||
beforeEach(() => vi.mocked(api.lanes.cloneFolder).mockReset());
|
beforeEach(() => {
|
||||||
|
vi.mocked(api.lanes.cloneFolder).mockReset();
|
||||||
|
// Unknown identity: nothing is checked up front.
|
||||||
|
vi.mocked(api.gitIdentity.get).mockReset().mockRejectedValue(new Error("old server"));
|
||||||
|
});
|
||||||
|
|
||||||
it("clones each URL, asks for a login where needed, and retries only that one", async () => {
|
it("clones each URL, asks for a login where needed, and retries only that one", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -53,9 +60,61 @@ describe("CloneSourcesForm", () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
|
||||||
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
|
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
|
||||||
|
progressToken: expect.any(String),
|
||||||
url: "https://private.test/b.git",
|
url: "https://private.test/b.git",
|
||||||
credentials: { username: "dev", password: "tok", remember: true },
|
credentials: { username: "dev", password: "tok", remember: true },
|
||||||
});
|
});
|
||||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
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" });
|
||||||
|
expect(cloneTarget("https://h.test/o/r.git")).toEqual({ kind: "https", host: "h.test" });
|
||||||
|
expect(cloneTarget("not a url")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not clone an SSH link before the account has a key, and flags an HTTPS host with no login", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue({
|
||||||
|
ssh: null,
|
||||||
|
sshKeys: [],
|
||||||
|
https: [],
|
||||||
|
} as unknown as GitIdentity);
|
||||||
|
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
|
||||||
|
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
|
||||||
|
await waitFor(() => expect(api.gitIdentity.get).toHaveBeenCalled());
|
||||||
|
|
||||||
|
await user.type(
|
||||||
|
screen.getByTestId("workspace-clone-input"),
|
||||||
|
"git@github.com:o/r.git\nhttps://h.test/a.git"
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("workspace-clone-need-ssh")).toHaveTextContent("github.com");
|
||||||
|
expect(screen.getByTestId("workspace-clone-need-login")).toHaveTextContent("h.test");
|
||||||
|
|
||||||
|
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",
|
||||||
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ import { confirmDialog } from "../../../lib/confirm";
|
|||||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
|
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
|
||||||
|
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
gitTarget: (lane: number, session?: string | null, source?: number | null) =>
|
||||||
|
source ? { lane, source } : session ? { lane, session } : lane,
|
||||||
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
|
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
|
||||||
api: {
|
api: {
|
||||||
lanes: {
|
lanes: {
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
|
clone: vi.fn(),
|
||||||
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
@@ -432,3 +434,87 @@ describe("FileExplorer", () => {
|
|||||||
expect(api.files.remove).not.toHaveBeenCalled();
|
expect(api.files.remove).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("FileExplorer — New → Clone repository", () => {
|
||||||
|
const openClone = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||||
|
await user.click(screen.getByRole("button", { name: "New" }));
|
||||||
|
await user.click(screen.getByRole("menuitem", { name: "Clone repository" }));
|
||||||
|
return screen.getByTestId("fx-clone-dialog");
|
||||||
|
};
|
||||||
|
|
||||||
|
it("clones into the open folder of the workspace and says where", async () => {
|
||||||
|
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||||
|
lane: { id: 9, title: "repo", cwd: "/w/one/src/repo" },
|
||||||
|
created: true,
|
||||||
|
path: "/w/one/src/repo",
|
||||||
|
} as never);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
fireEvent.doubleClick(await screen.findByText("src"));
|
||||||
|
await screen.findByText("x.js");
|
||||||
|
const dialog = await openClone(user);
|
||||||
|
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
|
||||||
|
expect(within(dialog).getByText(/\/w\/one\/src\/repo/)).toBeInTheDocument();
|
||||||
|
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.clone).toHaveBeenCalledWith({
|
||||||
|
url: "git@host:o/repo.git",
|
||||||
|
parent: "/w/one/src",
|
||||||
|
name: undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(toast.success).toHaveBeenCalled();
|
||||||
|
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for a login on ECREDENTIALS and sends it once", async () => {
|
||||||
|
vi.mocked(api.lanes.clone)
|
||||||
|
.mockRejectedValueOnce(
|
||||||
|
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
lane: { id: 9, title: "r", cwd: "/w/one/r" },
|
||||||
|
created: true,
|
||||||
|
path: "/w/one/r",
|
||||||
|
} as never);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await screen.findByText("a.txt");
|
||||||
|
const dialog = await openClone(user);
|
||||||
|
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
|
||||||
|
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||||
|
const login = await within(dialog).findByTestId("fx-clone-login");
|
||||||
|
await user.type(within(login).getByLabelText("Username"), "ann");
|
||||||
|
await user.type(within(login).getByLabelText("Password or access token"), "tok");
|
||||||
|
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.clone).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
parent: "/w/one",
|
||||||
|
credentials: { username: "ann", password: "tok", remember: true },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps typing in the dialog away from the explorer's shortcuts", async () => {
|
||||||
|
vi.mocked(api.files.remove).mockResolvedValue({ trashed: [] });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await screen.findByText("a.txt");
|
||||||
|
fireEvent.click(row("a.txt"));
|
||||||
|
const dialog = await openClone(user);
|
||||||
|
const url = within(dialog).getByLabelText("Repository URL");
|
||||||
|
await user.type(url, "abc{Backspace}{Delete}");
|
||||||
|
expect(api.files.remove).not.toHaveBeenCalled();
|
||||||
|
expect(url).toHaveValue("ab");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not offered in an extra folder", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<FileExplorer open laneId={1} source={3} onClose={vi.fn()} onChanged={vi.fn()} />);
|
||||||
|
await screen.findByText("a.txt");
|
||||||
|
await user.click(screen.getByRole("button", { name: "New" }));
|
||||||
|
expect(screen.queryByRole("menuitem", { name: "Clone repository" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
it("cherry-picks only from another branch's history", async () => {
|
||||||
withParent();
|
withParent();
|
||||||
vi.mocked(api.lanes.git).mockResolvedValue(facts());
|
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.
|
// As the Workspace page: `&run=` replaces the home with the session view.
|
||||||
function HomeUntilRun() {
|
function HomeUntilRun() {
|
||||||
const [params] = useSearchParams();
|
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();
|
const user = userEvent.setup();
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ import { PetSettings } from "../settings/PetSettings";
|
|||||||
import { Swatch, THEME_LABELS } from "../settings/AppearanceSettings";
|
import { Swatch, THEME_LABELS } from "../settings/AppearanceSettings";
|
||||||
import { markOnboarded, useOnboarding } from "../../lib/userPrefs";
|
import { markOnboarded, useOnboarding } from "../../lib/userPrefs";
|
||||||
import {
|
import {
|
||||||
DEFAULT_MODES,
|
|
||||||
readDefaultMode,
|
readDefaultMode,
|
||||||
readDefaultTool,
|
readDefaultTool,
|
||||||
|
SAFE_MODES,
|
||||||
writeDefaultMode,
|
writeDefaultMode,
|
||||||
writeDefaultTool,
|
writeDefaultTool,
|
||||||
type DefaultMode,
|
type DefaultMode,
|
||||||
@@ -310,7 +310,7 @@ function AgentStep() {
|
|||||||
writeDefaultMode(m);
|
writeDefaultMode(m);
|
||||||
setMode(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>
|
</div>
|
||||||
<p className="text-xs text-fg-muted">{t("agent.permissionHint")}</p>
|
<p className="text-xs text-fg-muted">{t("agent.permissionHint")}</p>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @file Onboarding.test.tsx
|
* @file Onboarding.test.tsx
|
||||||
* @description The first-run setup (docs/plans/onboarding): the account's
|
* @description The first-run setup (docs/plans/onboarding): the account's
|
||||||
* preferences are loaded into localStorage on sign-in and every later change
|
* 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
|
* 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.
|
* ends in the tour; the tour follows the real UI step by step.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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 { __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 { OnboardingWizard } from "../OnboardingWizard";
|
||||||
import { OnboardingTour, startTour } from "../OnboardingTour";
|
import { OnboardingTour, startTour } from "../OnboardingTour";
|
||||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
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");
|
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
|
||||||
expect(readDefaultMode()).toBe("auto");
|
expect(readDefaultMode()).toBe("bypassPermissions");
|
||||||
writeDefaultMode("bypassPermissions" as never);
|
expect(readDefaultMode({ safe: true })).toBe("auto");
|
||||||
|
expect(SAFE_MODES).not.toContain("bypassPermissions");
|
||||||
|
writeDefaultMode("nonsense" as never);
|
||||||
expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions");
|
expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions");
|
||||||
writeDefaultMode("plan");
|
writeDefaultMode("plan");
|
||||||
expect(readDefaultMode()).toBe("plan");
|
expect(readDefaultMode()).toBe("plan");
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ export function LaneConsolePane({
|
|||||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||||
autoFocus={autoFocus && !(chatable && chat)}
|
autoFocus={autoFocus && !(chatable && chat)}
|
||||||
covered={!!(chatable && chat)}
|
covered={!!(chatable && chat)}
|
||||||
|
tool={handle.tool}
|
||||||
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
// 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)
|
// 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.
|
// rather than leaving a dead terminal the user has to click out of.
|
||||||
|
|||||||
@@ -476,7 +476,10 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
|
|
||||||
// Stable, so typing in the prompt box does not re-render the conversation
|
// Stable, so typing in the prompt box does not re-render the conversation
|
||||||
// (ConversationView is memoized).
|
// (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(
|
const openFile = useCallback(
|
||||||
(named: string) => {
|
(named: string) => {
|
||||||
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
|
// 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 () => {
|
const send = async () => {
|
||||||
if (!text || uploading) return;
|
if (!text || uploading) return;
|
||||||
const out = promptForPane(prompt, images);
|
const out = promptForPane(prompt, images, claude);
|
||||||
if (!(await command(out.text, out.files))) return;
|
if (!(await command(out.text, out.files))) return;
|
||||||
setFollow((n) => n + 1);
|
setFollow((n) => n + 1);
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
|
|||||||
@@ -244,9 +244,9 @@ const ITEM =
|
|||||||
* the draft (its files as paths) back into the input box, never sent. */
|
* 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
|
/** A session's chat switched to its terminal: the unsent prompt goes into
|
||||||
* the CLI's input box, without Enter. */
|
* 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 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, "", []);
|
if ((text || files.length) && pasteToRun(runId, text, files)) writeChatDraft(sessionId, "", []);
|
||||||
setSessionChat(sessionId, false);
|
setSessionChat(sessionId, false);
|
||||||
}
|
}
|
||||||
@@ -265,7 +265,7 @@ function ChatToggle({ run }: { run?: RunHandle }) {
|
|||||||
title={label}
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (chat) return showTerminal(run.id, sessionId);
|
if (chat) return showTerminal(run.id, sessionId, run.tool);
|
||||||
const draft = readChatDraft(sessionId);
|
const draft = readChatDraft(sessionId);
|
||||||
const text = takeRunInput(run.id);
|
const text = takeRunInput(run.id);
|
||||||
if (text)
|
if (text)
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens";
|
|||||||
import {
|
import {
|
||||||
ATTACH_MIME,
|
ATTACH_MIME,
|
||||||
addAttachments,
|
addAttachments,
|
||||||
|
claudeMention,
|
||||||
droppedAttachments,
|
droppedAttachments,
|
||||||
droppedSkill,
|
droppedSkill,
|
||||||
droppedText,
|
droppedText,
|
||||||
@@ -203,21 +204,29 @@ interface PromptEditorProps {
|
|||||||
export type PromptImage = { path: string; url?: string; name: string };
|
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 prompt as sent: the text, then each file's saved path — which is how
|
||||||
* the CLI is handed an image or a file. */
|
* the CLI is handed an image or a file; for `claude`, as `@` mentions. */
|
||||||
export function promptWithImages(text: string, images: PromptImage[]): string {
|
export function promptWithImages(text: string, images: PromptImage[], claude = false): string {
|
||||||
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
|
return [text.trim(), ...images.map((i) => (claude ? claudeMention(i.path) : i.path))]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
const IMAGE_PATH = /^[^@].*\.(png|jpe?g|gif|webp)$/i;
|
const IMAGE_PATH = /^[^@].*\.(png|jpe?g|gif|webp)$/i;
|
||||||
|
|
||||||
/** The prompt as pasted into a live pane: the text with every non-image file
|
/** The prompt as pasted into a live pane. `claude`: one paste, every file an
|
||||||
* in it (as `promptWithImages`), and the images' paths apart — each goes in
|
* `@` mention, and a trailing space so a mention at the end does not leave
|
||||||
* a paste of its own, the one shape `claude` turns into `[Image #n]` on every
|
* the `@` file suggester open to swallow the Enter after it. Any other tool:
|
||||||
* platform (a Windows path inside a paste of text stayed a plain path). */
|
* 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(
|
export function promptForPane(
|
||||||
text: string,
|
text: string,
|
||||||
images: PromptImage[]
|
images: PromptImage[],
|
||||||
|
claude = false
|
||||||
): { text: string; files: string[] } {
|
): { 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);
|
const isImage = (i: PromptImage) => IMAGE_PATH.test(i.path);
|
||||||
return {
|
return {
|
||||||
text: promptWithImages(text, images.filter((i) => !isImage(i))),
|
text: promptWithImages(text, images.filter((i) => !isImage(i))),
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
* (`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
|
* 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
|
* (`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
|
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
|
||||||
* a file. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
* 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
|
* `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.
|
* pasted into whichever of the workspace's panes was focused last.
|
||||||
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
* 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 { api } from "../../lib/api";
|
||||||
import { writeClipboard } from "../../lib/clipboard";
|
import { writeClipboard } from "../../lib/clipboard";
|
||||||
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
|
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||||
|
import { claudeMention } from "../../lib/promptAttach";
|
||||||
import { attachImeInput } from "../../lib/imeInput";
|
import { attachImeInput } from "../../lib/imeInput";
|
||||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||||
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
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
|
* 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). */
|
* layout size (no PTY resize; the chat still reads and answers its menus). */
|
||||||
covered?: boolean;
|
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
|
/** Memoized: the Workspace re-renders its pane tree on every lane_update, and
|
||||||
@@ -170,6 +174,7 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
onExit,
|
onExit,
|
||||||
autoFocus = true,
|
autoFocus = true,
|
||||||
covered = false,
|
covered = false,
|
||||||
|
tool,
|
||||||
}: TerminalViewProps) {
|
}: TerminalViewProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
// Read through a ref: an inline `onExit` from the parent changes identity on
|
// 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)));
|
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
|
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
||||||
// sees pasted paths rather than typed keystrokes.
|
// 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();
|
termRef.current?.focus();
|
||||||
setDrop(null);
|
setDrop(null);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -462,6 +462,8 @@ describe("PaneChat — the unsent prompt", () => {
|
|||||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||||
expect(submitToRun).toHaveBeenCalledWith(
|
expect(submitToRun).toHaveBeenCalledWith(
|
||||||
"agenthub-lane-1",
|
"agenthub-lane-1",
|
||||||
|
// 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 ',
|
'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;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -220,6 +220,15 @@
|
|||||||
"clone.remember": "Remember this login for my account",
|
"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.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.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",
|
"moreActions": "More actions",
|
||||||
"pipelinePicker.label": "Pipeline template",
|
"pipelinePicker.label": "Pipeline template",
|
||||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
|
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
|
||||||
@@ -298,6 +307,7 @@
|
|||||||
"changes.undoCommitTitle": "Undo the last commit?",
|
"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.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.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.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.cherryPickConfirm": "Apply {{short}} \"{{subject}}\" from {{from}} onto {{branch}} as a new commit? Files in this workspace change now.",
|
||||||
"changes.amendTitle": "Amend the last commit?",
|
"changes.amendTitle": "Amend the last commit?",
|
||||||
@@ -421,6 +431,9 @@
|
|||||||
"home.cloneRun": "Clone",
|
"home.cloneRun": "Clone",
|
||||||
"home.cloneRetry": "Retry the failed ones",
|
"home.cloneRetry": "Retry the failed ones",
|
||||||
"home.cloneDone": "Added",
|
"home.cloneDone": "Added",
|
||||||
|
"home.cloneNeedSsh": "SSH links need an SSH key first — your account has none. Make one in Settings → Git and add its public key to {{hosts}} before cloning.",
|
||||||
|
"home.cloneNeedLogin": "No saved login for {{hosts}}. A private repository needs your username and an access token (or save one in Settings → Git).",
|
||||||
|
"home.cloneEnterLogin": "Enter a login",
|
||||||
"home.suggested": "Untracked worktrees",
|
"home.suggested": "Untracked worktrees",
|
||||||
"home.remove": "Remove",
|
"home.remove": "Remove",
|
||||||
"home.missing": "missing",
|
"home.missing": "missing",
|
||||||
@@ -548,6 +561,9 @@
|
|||||||
"fx.refresh": "Refresh",
|
"fx.refresh": "Refresh",
|
||||||
"fx.newFolder": "New folder",
|
"fx.newFolder": "New folder",
|
||||||
"fx.newFile": "New file",
|
"fx.newFile": "New file",
|
||||||
|
"fx.cloneRepo": "Clone repository",
|
||||||
|
"fx.cloneSubmit": "Clone",
|
||||||
|
"fx.cloned": "Cloned into {{path}} — opened as workspace “{{workspace}}”.",
|
||||||
"fx.uploadFiles": "Upload files",
|
"fx.uploadFiles": "Upload files",
|
||||||
"fx.uploadFolder": "Upload a folder",
|
"fx.uploadFolder": "Upload a folder",
|
||||||
"fx.download": "Download",
|
"fx.download": "Download",
|
||||||
|
|||||||
@@ -50,6 +50,9 @@
|
|||||||
"permissionAuto": "auto (recommended - asks only for risky actions)",
|
"permissionAuto": "auto (recommended - asks only for risky actions)",
|
||||||
"permissionBypass": "bypassPermissions (DANGER - allows everything without asking)",
|
"permissionBypass": "bypassPermissions (DANGER - allows everything without asking)",
|
||||||
"bypass": "Bypass permissions for this run",
|
"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)",
|
"permissionDefault": "default (interactive prompts - sessions may hang headlessly)",
|
||||||
"permissionMode": "Permission mode",
|
"permissionMode": "Permission mode",
|
||||||
"permissionPlan": "plan (read-only planning)",
|
"permissionPlan": "plan (read-only planning)",
|
||||||
|
|||||||
@@ -210,7 +210,13 @@
|
|||||||
"moreSteps_other": "+{{count}} more",
|
"moreSteps_other": "+{{count}} more",
|
||||||
"quiet": "no new activity for {{time}}",
|
"quiet": "no new activity for {{time}}",
|
||||||
"askDeclined": "Declined — no answer given",
|
"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": {
|
"overview": {
|
||||||
"agent": "Agent",
|
"agent": "Agent",
|
||||||
|
|||||||
@@ -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.",
|
"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}}"
|
"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": {
|
"agentInstructions": {
|
||||||
"title": "My agent instructions",
|
"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.",
|
"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.",
|
||||||
|
|||||||
@@ -220,6 +220,15 @@
|
|||||||
"clone.remember": "Lưu đăng nhập này cho tài khoản của tôi",
|
"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.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.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",
|
"moreActions": "Thêm hành động",
|
||||||
"pipelinePicker.label": "Mẫu pipeline",
|
"pipelinePicker.label": "Mẫu pipeline",
|
||||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
|
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
|
||||||
@@ -298,6 +307,7 @@
|
|||||||
"changes.undoCommitTitle": "Hoàn tác commit cuối?",
|
"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.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.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.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.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?",
|
"changes.amendTitle": "Amend commit cuối?",
|
||||||
@@ -421,6 +431,9 @@
|
|||||||
"home.cloneRun": "Clone",
|
"home.cloneRun": "Clone",
|
||||||
"home.cloneRetry": "Thử lại các repo lỗi",
|
"home.cloneRetry": "Thử lại các repo lỗi",
|
||||||
"home.cloneDone": "Đã thêm",
|
"home.cloneDone": "Đã thêm",
|
||||||
|
"home.cloneNeedSsh": "Link SSH cần SSH key trước — tài khoản của bạn chưa có. Hãy tạo key trong Cài đặt → Git và thêm public key vào {{hosts}} trước khi clone.",
|
||||||
|
"home.cloneNeedLogin": "Chưa lưu login cho {{hosts}}. Repo private cần username và access token của bạn (hoặc lưu sẵn trong Cài đặt → Git).",
|
||||||
|
"home.cloneEnterLogin": "Nhập login",
|
||||||
"home.suggested": "Worktree chưa theo dõi",
|
"home.suggested": "Worktree chưa theo dõi",
|
||||||
"home.remove": "Gỡ",
|
"home.remove": "Gỡ",
|
||||||
"home.missing": "không còn",
|
"home.missing": "không còn",
|
||||||
@@ -548,6 +561,9 @@
|
|||||||
"fx.refresh": "Làm mới",
|
"fx.refresh": "Làm mới",
|
||||||
"fx.newFolder": "Thư mục mới",
|
"fx.newFolder": "Thư mục mới",
|
||||||
"fx.newFile": "File mới",
|
"fx.newFile": "File mới",
|
||||||
|
"fx.cloneRepo": "Clone repository",
|
||||||
|
"fx.cloneSubmit": "Clone",
|
||||||
|
"fx.cloned": "Đã clone vào {{path}} — đã mở thành workspace “{{workspace}}”.",
|
||||||
"fx.uploadFiles": "Tải file lên",
|
"fx.uploadFiles": "Tải file lên",
|
||||||
"fx.uploadFolder": "Tải cả thư mục lên",
|
"fx.uploadFolder": "Tải cả thư mục lên",
|
||||||
"fx.download": "Tải về",
|
"fx.download": "Tải về",
|
||||||
|
|||||||
@@ -50,6 +50,9 @@
|
|||||||
"permissionAuto": "auto (khuyên dùng - chỉ hỏi khi hành động rủi ro)",
|
"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)",
|
"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",
|
"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)",
|
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
|
||||||
"permissionMode": "Chế độ quyền",
|
"permissionMode": "Chế độ quyền",
|
||||||
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
|
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
|
||||||
|
|||||||
@@ -210,7 +210,13 @@
|
|||||||
"moreSteps_other": "+{{count}} việc nữa",
|
"moreSteps_other": "+{{count}} việc nữa",
|
||||||
"quiet": "không có hoạt động mới trong {{time}}",
|
"quiet": "không có hoạt động mới trong {{time}}",
|
||||||
"askDeclined": "Đã từ chối — không trả lời",
|
"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": {
|
"overview": {
|
||||||
"agent": "Agent",
|
"agent": "Agent",
|
||||||
|
|||||||
@@ -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.",
|
"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}}"
|
"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": {
|
"agentInstructions": {
|
||||||
"title": "Hướng dẫn agent của tôi",
|
"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.",
|
"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.",
|
||||||
|
|||||||
+37
-1
@@ -866,6 +866,9 @@ export interface FsHome {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
|
/** The caller's own home only (`me`): its absolute folder — where New →
|
||||||
|
* Clone repository clones to. Absent from an older server. */
|
||||||
|
dir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One entry of a File Explorer folder listing (`GET /api/lanes/:id/fs/list`). */
|
/** One entry of a File Explorer folder listing (`GET /api/lanes/:id/fs/list`). */
|
||||||
@@ -3573,11 +3576,22 @@ export const api = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
tool?: LaneTool;
|
tool?: LaneTool;
|
||||||
credentials?: { username: string; password: string; remember?: boolean };
|
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", {
|
request<{ lane: Lane; created: boolean; path: string }>("/lanes/clone", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
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
|
* GET /api/lanes/pipelines — every pipeline template the server can render
|
||||||
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
|
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
|
||||||
@@ -3974,6 +3988,9 @@ export const api = {
|
|||||||
url: string;
|
url: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
credentials?: { username: string; password: string; remember?: boolean };
|
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`, {
|
request<LaneSourcesView & { path: string }>(`/lanes/${id}/sources/clone`, {
|
||||||
@@ -4429,6 +4446,8 @@ export interface AiTool {
|
|||||||
prompt: { mode: "argv" | "flag" | "after-start" } | null;
|
prompt: { mode: "argv" | "flag" | "after-start" } | null;
|
||||||
/** Offers "Resume last session". */
|
/** Offers "Resume last session". */
|
||||||
resume: boolean;
|
resume: boolean;
|
||||||
|
/** Can resume a NAMED session (Claude, Codex, a catalog `resumeById`). */
|
||||||
|
resumeById?: boolean;
|
||||||
/** Offers the per-run "Bypass permissions" switch. */
|
/** Offers the per-run "Bypass permissions" switch. */
|
||||||
bypass: boolean;
|
bypass: boolean;
|
||||||
/** Can use an AI Proxy profile (Claude, Codex); others are native only. */
|
/** Can use an AI Proxy profile (Claude, Codex); others are native only. */
|
||||||
@@ -5212,7 +5231,7 @@ export interface RunStartArgs {
|
|||||||
cwd?: string;
|
cwd?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
permissionMode?: PermissionMode;
|
permissionMode?: PermissionMode;
|
||||||
/** Resume an existing Claude Code session id (`--resume`) instead of starting fresh. */
|
/** Resume an existing session id (Claude/Codex, or a catalog `resumeById` tool) instead of starting fresh. */
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
/** Sent as `claude`'s first positional message once the pane boots; omit
|
/** Sent as `claude`'s first positional message once the pane boots; omit
|
||||||
@@ -5285,6 +5304,23 @@ export interface LaneSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Response shape of GET /api/lanes/:id/sources (and of every sources write). */
|
/** 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 {
|
export interface LaneSourcesView {
|
||||||
instructions: string;
|
instructions: string;
|
||||||
/** The workspace's tool can take folders and instructions (claude, codex). */
|
/** The workspace's tool can take folders and instructions (claude, codex). */
|
||||||
|
|||||||
@@ -14,9 +14,10 @@
|
|||||||
* terminal moves the unsent prompt across (`takeRunInput` reads the TUI's
|
* 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
|
* 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.
|
* 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`
|
* Attached images' paths go after the text, each in a paste of its own — how
|
||||||
* turns a paste that is only an image's path into `[Image #n]`, but left a
|
* `codex` attaches one; a `claude` pane gets every file as an `@` mention in
|
||||||
* Windows path (`C:\…\drops\…png`) inside a paste of text as a plain path.
|
* 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
|
* The Chat view also answers a menu the CLI asks with (`parsePaneMenu` reads
|
||||||
* it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the
|
* it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the
|
||||||
* arrows and Enter, or type into its "Type something" row).
|
* arrows and Enter, or type into its "Type something" row).
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
* file or folder dragged from the Files panel (`ATTACH_MIME`, read back by
|
* file or folder dragged from the Files panel (`ATTACH_MIME`, read back by
|
||||||
* `PromptEditor`'s drop) or from a Chat pane's activity panel
|
* `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
|
* (`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
|
* last day (`recentUploads`, kept in this browser), and `claudeMention`, the
|
||||||
* sent with the prompt, so nothing here touches the server.
|
* `@` 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>
|
* @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";
|
export const SKILL_MIME = "application/x-agenthub-skill";
|
||||||
// A skill's name as Claude Code types it: `name` or `plugin:name`.
|
// A skill's name as Claude Code types it: `name` or `plugin:name`.
|
||||||
const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/;
|
const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/;
|
||||||
|
|||||||
@@ -1,22 +1,37 @@
|
|||||||
/**
|
/**
|
||||||
* @file sessionDefaults.ts
|
* @file sessionDefaults.ts
|
||||||
* @description The account's own starting choices for new work, picked in the
|
* @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
|
* first-run setup (docs/plans/onboarding) or Settings → AI tools → Session
|
||||||
* other preference (`lib/userPrefs.ts`): the AI a new workspace starts on, and
|
* defaults, and synced per account like every other preference
|
||||||
* the permission mode a new Claude session starts in. Bypass is NEVER one of
|
* (`lib/userPrefs.ts`): the AI a new workspace starts on, the permission mode
|
||||||
* them — it stays the per-session switch (CLAUDE.md), so `bypassPermissions`
|
* a new Claude session starts in, and a new Codex session's sandbox, approval
|
||||||
* is refused on read and on write.
|
* 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>
|
* @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 TOOL_KEY = "agenthub.defaultTool";
|
||||||
const MODE_KEY = "agenthub.permissionMode";
|
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. */
|
/** 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];
|
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 {
|
function read(key: string): string | null {
|
||||||
try {
|
try {
|
||||||
@@ -37,11 +52,36 @@ function write(key: string, value: string): void {
|
|||||||
export const readDefaultTool = (): string | null => read(TOOL_KEY);
|
export const readDefaultTool = (): string | null => read(TOOL_KEY);
|
||||||
export const writeDefaultTool = (id: string): void => write(TOOL_KEY, id);
|
export const writeDefaultTool = (id: string): void => write(TOOL_KEY, id);
|
||||||
|
|
||||||
/** The permission mode a new Claude session starts in ("auto" when none was picked). */
|
/** The permission mode a new Claude session starts in ("auto" when none was
|
||||||
export function readDefaultMode(): PermissionMode {
|
* picked); `safe` turns a bypass default into "auto". */
|
||||||
|
export function readDefaultMode({ safe = false }: { safe?: boolean } = {}): PermissionMode {
|
||||||
const v = read(MODE_KEY);
|
const v = read(MODE_KEY);
|
||||||
|
if (safe && v === "bypassPermissions") return "auto";
|
||||||
return (DEFAULT_MODES as readonly string[]).includes(v ?? "") ? (v as DefaultMode) : "auto";
|
return (DEFAULT_MODES as readonly string[]).includes(v ?? "") ? (v as DefaultMode) : "auto";
|
||||||
}
|
}
|
||||||
export function writeDefaultMode(mode: DefaultMode): void {
|
export function writeDefaultMode(mode: DefaultMode): void {
|
||||||
if ((DEFAULT_MODES as readonly string[]).includes(mode)) write(MODE_KEY, mode);
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -732,7 +732,10 @@ export interface Session {
|
|||||||
/** The AI coding CLI that produced the session: `"claude"` (the default —
|
/** The AI coding CLI that produced the session: `"claude"` (the default —
|
||||||
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
|
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
|
||||||
* journals and hooks), `"antigravity"` (agy's conversation files and
|
* journals and hooks), `"antigravity"` (agy's conversation files and
|
||||||
* hooks) or `"cursor"` (Cursor's agent transcripts, no tokens). Attach/resume offer `claude` rows only. Maps to `sessions.tool`. */
|
* hooks), `"cursor"` (Cursor's agent transcripts, no tokens), `"omp"`,
|
||||||
|
* `"opencode"` (their own session stores, with tokens and subagents) or
|
||||||
|
* `"grok"` (Grok running the Claude hooks, no tokens). Maps to
|
||||||
|
* `sessions.tool`. */
|
||||||
tool?: string;
|
tool?: string;
|
||||||
/** The workspace (lane id) that owns the session — the one whose pane held
|
/** The workspace (lane id) that owns the session — the one whose pane held
|
||||||
* it; null for a session no workspace started.
|
* it; null for a session no workspace started.
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export const PREF_KEYS = [
|
|||||||
"agent-monitor-notifications",
|
"agent-monitor-notifications",
|
||||||
"agenthub.defaultTool",
|
"agenthub.defaultTool",
|
||||||
"agenthub.permissionMode",
|
"agenthub.permissionMode",
|
||||||
|
"agenthub.codex.sandbox",
|
||||||
|
"agenthub.codex.approval",
|
||||||
|
"agenthub.codex.bypass",
|
||||||
"agenthub.tour",
|
"agenthub.tour",
|
||||||
"agenthub.workspace.allTabs",
|
"agenthub.workspace.allTabs",
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import { SnapshotSettings } from "../components/settings/SnapshotSettings";
|
|||||||
import { ReportSettings } from "../components/settings/ReportSettings";
|
import { ReportSettings } from "../components/settings/ReportSettings";
|
||||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||||
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
|
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
|
||||||
|
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
|
||||||
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
||||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||||
@@ -292,6 +293,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
|||||||
render: () => (
|
render: () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<AgentInstructionsSettings />
|
<AgentInstructionsSettings />
|
||||||
|
<SessionDefaultsSettings />
|
||||||
<AiToolsSettings settingsHref={toolHref} />
|
<AiToolsSettings settingsHref={toolHref} />
|
||||||
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
||||||
<ArtifactsHubSettings />
|
<ArtifactsHubSettings />
|
||||||
|
|||||||
@@ -7239,7 +7239,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.8.11 (rebuild client to match)
|
UI build v2.8.12 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+32
-5
@@ -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 |
|
| `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 |
|
| `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.
|
`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.
|
||||||
|
|
||||||
@@ -298,7 +298,7 @@ re-run.
|
|||||||
#### File Explorer on a home
|
#### File Explorer on a home
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/homes { homes: [{ id: "me" | <username>, label, readOnly }] }
|
GET /api/homes { homes: [{ id: "me" | <username>, label, readOnly, dir? }] }
|
||||||
GET /api/homes/:who/fs/list|trash|zip|download as the workspace routes above
|
GET /api/homes/:who/fs/list|trash|zip|download as the workspace routes above
|
||||||
POST /api/homes/:who/fs/mkdir|newfile|rename|move|copy|delete|upload|trash/restore|trash/purge
|
POST /api/homes/:who/fs/mkdir|newfile|rename|move|copy|delete|upload|trash/restore|trash/purge
|
||||||
```
|
```
|
||||||
@@ -341,14 +341,41 @@ A copy goes home ↔ workspace
|
|||||||
one Linux account; each side is checked under its own rule. A home is never a
|
one Linux account; each side is checked under its own rule. A home is never a
|
||||||
workspace. The **Files** page opens on `?home=me` by default.
|
workspace. The **Files** page opens on `?home=me` by default.
|
||||||
|
|
||||||
|
`me` alone carries `dir`, its absolute folder: the File Explorer's **New →
|
||||||
|
Clone repository** (`client/src/components/lanes/CloneHereDialog.tsx`) clones
|
||||||
|
into the folder it has open — of the user's own home or of a workspace's own
|
||||||
|
folder, never a session worktree, an extra folder or another user's home —
|
||||||
|
through `POST /api/lanes/clone` below with that folder as `parent`, so every
|
||||||
|
check of that route applies, and opens a workspace on the clone.
|
||||||
|
|
||||||
#### Clone a repository into a new workspace
|
#### 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 }
|
→ 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` —
|
`publish` (`https://` with no userinfo, `ssh://`, scp-like `user@host:path` —
|
||||||
else `400 EBADURL`); `name` (default: the URL's last segment without `.git`)
|
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`);
|
is one plain folder name that must not exist (`400 EBADNAME`, `409 EEXISTS`);
|
||||||
@@ -1593,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.
|
- **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.
|
- **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.
|
- **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`).
|
- **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.
|
- **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`.
|
- Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`.
|
||||||
|
|||||||
@@ -259,6 +259,8 @@ CODEX_HOME= # where Codex keeps sessions/ (default ~
|
|||||||
DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity sessions/tokens (60 s while idle)
|
DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity sessions/tokens (60 s while idle)
|
||||||
AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini)
|
AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini)
|
||||||
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens; 60 s while idle)
|
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens; 60 s while idle)
|
||||||
|
DASHBOARD_OMP_POLL_MS=15000 # omp session journals → Oh My Pi sessions/tokens/subagents (60 s while idle)
|
||||||
|
DASHBOARD_OPENCODE_POLL_MS=15000 # opencode.db → OpenCode sessions/tokens/subagents (60 s while idle)
|
||||||
AGENTHUB_CURSOR_DIR= # where Cursor keeps its data (default ~/.cursor)
|
AGENTHUB_CURSOR_DIR= # where Cursor keeps its data (default ~/.cursor)
|
||||||
AGENTHUB_AGY_USAGE= # 0 never runs `agy --print /usage` for the Usage panel's Antigravity quota (default on; running agy may update it)
|
AGENTHUB_AGY_USAGE= # 0 never runs `agy --print /usage` for the Usage panel's Antigravity quota (default on; running agy may update it)
|
||||||
|
|
||||||
|
|||||||
+2
-2
File diff suppressed because one or more lines are too long
@@ -302,6 +302,22 @@ default 15000) and appear in Sessions with a **Cursor CLI** badge. Cursor writes
|
|||||||
no token counts or model ids there, so a Cursor session shows no tokens and no
|
no token counts or model ids there, so a Cursor session shows no tokens and no
|
||||||
cost.
|
cost.
|
||||||
|
|
||||||
|
**Oh My Pi (`omp`)** sessions are read from omp's session journals
|
||||||
|
(`~/.omp/agent/sessions/*/<timestamp>_<id>.jsonl`, every `DASHBOARD_OMP_POLL_MS`
|
||||||
|
ms, default 15000) — each member's own `~/.omp` too on a multi-user server — and
|
||||||
|
appear in Sessions with an **Oh My Pi (OMP)** badge, with their tokens and cost
|
||||||
|
per model and one agent per subagent the `task` tool spawned (its journal sits in
|
||||||
|
the folder named like the session's). An omp session can be resumed by id from
|
||||||
|
its workspace (`omp --resume <id>`), like a Claude or Codex one.
|
||||||
|
|
||||||
|
**OpenCode** sessions are read from its SQLite store
|
||||||
|
(`~/.local/share/opencode/opencode.db`, every `DASHBOARD_OPENCODE_POLL_MS` ms),
|
||||||
|
with their tokens and cost per model and one agent per subagent, and are
|
||||||
|
resumable by id (`opencode --session <id>`). **Grok Build** runs the Claude hooks in
|
||||||
|
`~/.claude/settings.json` itself, so its sessions arrive through AgentHub's own
|
||||||
|
hooks, recorded as Grok sessions (live status, no tokens); they resume with
|
||||||
|
`grok --resume <id>`. Cursor sessions resume by id too (`--resume <id>`).
|
||||||
|
|
||||||
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
|
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
|
||||||
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
||||||
[`docs/LANES.md`](LANES.md#custom-pipeline-templates)); until then
|
[`docs/LANES.md`](LANES.md#custom-pipeline-templates)); until then
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Asked 2026-10-08 (modelled on Orca's 3-step welcome). Decisions:
|
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`.
|
- **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.**
|
- **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).
|
- **On save:** wrapping `localStorage.setItem` for `PREF_KEYS` pushes each change (debounced).
|
||||||
- [x] New preference keys:
|
- [x] New preference keys:
|
||||||
- `agenthub.defaultTool`, read by AddLaneModal before the install default;
|
- `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:
|
- [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.
|
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.
|
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
@@ -3,7 +3,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Agent Dashboard for Claude Code API
|
title: Agent Dashboard for Claude Code API
|
||||||
version: 2.8.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.
|
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||||
contact:
|
contact:
|
||||||
name: Nguyễn Ngọc Trí Vĩ
|
name: Nguyễn Ngọc Trí Vĩ
|
||||||
@@ -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`.'
|
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
|
- 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.
|
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:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
SessionIdPath:
|
SessionIdPath:
|
||||||
@@ -3859,7 +3863,7 @@ components:
|
|||||||
example: claude-opus-4-8
|
example: claude-opus-4-8
|
||||||
resumeSessionId:
|
resumeSessionId:
|
||||||
type: string
|
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
|
example: a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d
|
||||||
effort:
|
effort:
|
||||||
type: string
|
type: string
|
||||||
@@ -4218,6 +4222,9 @@ components:
|
|||||||
resume:
|
resume:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Offers Resume last session
|
description: Offers Resume last session
|
||||||
|
resumeById:
|
||||||
|
type: boolean
|
||||||
|
description: Can resume a named session (Claude, Codex, a catalog `resumeById` such as omp)
|
||||||
bypass:
|
bypass:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Offers the per-run Bypass permissions switch
|
description: Offers the per-run Bypass permissions switch
|
||||||
@@ -4928,6 +4935,71 @@ components:
|
|||||||
- replace
|
- replace
|
||||||
default: fail
|
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.'
|
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:
|
paths:
|
||||||
/api/health:
|
/api/health:
|
||||||
get:
|
get:
|
||||||
@@ -16290,9 +16362,34 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
remember:
|
remember:
|
||||||
type: boolean
|
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:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: '{lane, created: true, path}'
|
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:
|
/api/me/git:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -16491,6 +16588,583 @@ paths:
|
|||||||
description: The identity.
|
description: The identity.
|
||||||
'400':
|
'400':
|
||||||
description: EBADAUTHOR
|
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:
|
/api/settings/import:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.11",
|
"version": "2.8.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.11",
|
"version": "2.8.12",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.11",
|
"version": "2.8.12",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -638,6 +638,33 @@ An `antigravity` workspace carries no pipeline either.
|
|||||||
`DASHBOARD_CURSOR_POLL_MS` ms (default 15000, `0` disables) into `sessions`
|
`DASHBOARD_CURSOR_POLL_MS` ms (default 15000, `0` disables) into `sessions`
|
||||||
rows (`tool = 'cursor'`) — no `token_usage`, the transcripts carry none —
|
rows (`tool = 'cursor'`) — no `token_usage`, the transcripts carry none —
|
||||||
and `cursor_transcripts` holds the mtime read for each file.
|
and `cursor_transcripts` holds the mtime read for each file.
|
||||||
|
- **Oh My Pi sessions:** `server/lib/omp-usage.js` reads omp's session journals
|
||||||
|
(`~/.omp/agent/sessions/<encoded cwd>/<timestamp>_<id>.jsonl`, plus every
|
||||||
|
subagent journal under `<timestamp>_<id>/`) every `DASHBOARD_OMP_POLL_MS` ms
|
||||||
|
(default 15000, `0` disables), each from the offset the last pass stopped at,
|
||||||
|
into `sessions` (`tool = 'omp'`), `token_usage` (the session's own and its
|
||||||
|
subagents' turns, per model) and `agents` (one `omp-<session>-<key>` subagent
|
||||||
|
row per subagent journal). `omp_sessions` holds each journal's offset and
|
||||||
|
what it said so far; `GET /api/sessions/:id/transcript(s)` reads an omp
|
||||||
|
session's journals from there. Under S3 each member's `~/.omp` is read only
|
||||||
|
through `openSafeAsync`.
|
||||||
|
- **OpenCode sessions:** `server/lib/opencode-usage.js` reads OpenCode's
|
||||||
|
SQLite store (`~/.local/share/opencode/opencode.db`, under `XDG_DATA_HOME`
|
||||||
|
when set) every `DASHBOARD_OPENCODE_POLL_MS` ms (default 15000, `0`
|
||||||
|
disables), when its file or WAL changed: each root `session` row becomes a
|
||||||
|
`sessions` row (`tool = 'opencode'`), its tree's assistant `message` tokens
|
||||||
|
per `modelID` become `token_usage` (reasoning counted as output), and each
|
||||||
|
child session (the `task` tool's) an `opencode-<id>` subagent row.
|
||||||
|
`opencode_stores` holds each store's mtime and `opencode_sessions` each root
|
||||||
|
session's newest `time_updated`; the transcript routes read the conversation
|
||||||
|
(messages + parts) from the store itself. A member's store and WAL are copied
|
||||||
|
through the guard before they are opened.
|
||||||
|
- **Grok sessions:** Grok runs the hooks in `~/.claude/settings.json` (its
|
||||||
|
Claude compatibility), so AgentHub's own hook reaches `POST /api/hooks/event`
|
||||||
|
with no `tool`; `routes/hooks.js` tells it apart by the camelCase keys only
|
||||||
|
Grok sends (`hookEventName` + `workspaceRoot`), records it as `tool = 'grok'`
|
||||||
|
and drops its `transcript_path` (Grok's file, never parsed as a Claude
|
||||||
|
transcript). No tokens: Grok's session files are not read.
|
||||||
- **Quiet sessions:** agy fires no exit hook, so a session quiet for ten
|
- **Quiet sessions:** agy fires no exit hook, so a session quiet for ten
|
||||||
minutes (with hook events) or two (without) is settled to `completed`, its
|
minutes (with hook events) or two (without) is settled to `completed`, its
|
||||||
agents too, and sets its workspace idle. That workspace must be one the hooks left
|
agents too, and sets its workspace idle. That workspace must be one the hooks left
|
||||||
|
|||||||
@@ -190,16 +190,16 @@ describe("install / update / icons", () => {
|
|||||||
it("starts an install job and reports it", async () => {
|
it("starts an install job and reports it", async () => {
|
||||||
jobs.__reset();
|
jobs.__reset();
|
||||||
assert.equal((await request("GET", "/api/ai-tools/job")).body, null);
|
assert.equal((await request("GET", "/api/ai-tools/job")).body, null);
|
||||||
const r = await request("POST", "/api/ai-tools/copilot/install");
|
const r = await request("POST", "/api/ai-tools/codex/install");
|
||||||
assert.equal(r.status, 202);
|
assert.equal(r.status, 202);
|
||||||
assert.equal(r.body.job.tool, "copilot");
|
assert.equal(r.body.job.tool, "codex");
|
||||||
const done = await settle();
|
const done = await settle();
|
||||||
assert.equal(done.status, "ok");
|
assert.equal(done.status, "ok");
|
||||||
assert.deepEqual(spawned.at(-1), [
|
assert.deepEqual(spawned.at(-1), [
|
||||||
path.join(BIN, "npm"),
|
path.join(BIN, "npm"),
|
||||||
"install",
|
"install",
|
||||||
"-g",
|
"-g",
|
||||||
"@github/copilot@latest",
|
"@openai/codex@latest",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,7 +216,7 @@ describe("install / update / icons", () => {
|
|||||||
const notNpm = await request("POST", "/api/ai-tools/opencode/update");
|
const notNpm = await request("POST", "/api/ai-tools/opencode/update");
|
||||||
assert.equal(notNpm.status, 409);
|
assert.equal(notNpm.status, 409);
|
||||||
assert.equal(notNpm.body.error.code, "ENOTNPM");
|
assert.equal(notNpm.body.error.code, "ENOTNPM");
|
||||||
const cross = await request("POST", "/api/ai-tools/copilot/install", undefined, {
|
const cross = await request("POST", "/api/ai-tools/codex/install", undefined, {
|
||||||
Origin: "https://evil.test",
|
Origin: "https://evil.test",
|
||||||
});
|
});
|
||||||
assert.equal(cross.status, 403);
|
assert.equal(cross.status, 403);
|
||||||
@@ -225,7 +225,7 @@ describe("install / update / icons", () => {
|
|||||||
it("answers EBUSY while a job runs", async () => {
|
it("answers EBUSY while a job runs", async () => {
|
||||||
jobs.__reset({ timeoutMs: 50 });
|
jobs.__reset({ timeoutMs: 50 });
|
||||||
hang = true;
|
hang = true;
|
||||||
assert.equal((await request("POST", "/api/ai-tools/copilot/install")).status, 202);
|
assert.equal((await request("POST", "/api/ai-tools/codex/install")).status, 202);
|
||||||
const busy = await request("POST", "/api/ai-tools/amp/install");
|
const busy = await request("POST", "/api/ai-tools/amp/install");
|
||||||
assert.equal(busy.status, 409);
|
assert.equal(busy.status, 409);
|
||||||
assert.equal(busy.body.error.code, "EBUSY");
|
assert.equal(busy.body.error.code, "EBUSY");
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ describe("enable / default", () => {
|
|||||||
launcher: null,
|
launcher: null,
|
||||||
prompt: { mode: "flag" },
|
prompt: { mode: "flag" },
|
||||||
resume: false,
|
resume: false,
|
||||||
|
resumeById: false,
|
||||||
bypass: false,
|
bypass: false,
|
||||||
proxyCapable: false,
|
proxyCapable: false,
|
||||||
modelFlag: false,
|
modelFlag: false,
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ const EXPECTED_API_PATHS = [
|
|||||||
"/api/webhooks/{id}",
|
"/api/webhooks/{id}",
|
||||||
"/api/webhooks/{id}/test",
|
"/api/webhooks/{id}/test",
|
||||||
"/api/webhooks/{id}/deliveries",
|
"/api/webhooks/{id}/deliveries",
|
||||||
|
"/api/artifacts",
|
||||||
|
"/api/artifacts/{id}/install",
|
||||||
|
"/api/host",
|
||||||
"/api/openapi.json",
|
"/api/openapi.json",
|
||||||
"/api/docs",
|
"/api/docs",
|
||||||
"/api/redoc",
|
"/api/redoc",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -64,6 +64,8 @@ before(async () => {
|
|||||||
"init",
|
"init",
|
||||||
]);
|
]);
|
||||||
execFileSync("git", ["clone", "-q", "--bare", src, bare]);
|
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) => {
|
remote = http.createServer((_req, res) => {
|
||||||
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' });
|
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' });
|
||||||
@@ -183,6 +185,61 @@ describe("POST /api/lanes/clone", () => {
|
|||||||
assert.equal(r.body.error.code, "ENOREPO");
|
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 () => {
|
it("refuses a cross-origin request", async () => {
|
||||||
const r = await request(
|
const r = await request(
|
||||||
"POST",
|
"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$/
|
/^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"]);
|
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", () => {
|
it("gives a member's fetch their own store, and puts a typed login in it only for the call", () => {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests POST /api/hooks/event for a hook Grok Build fired through the
|
||||||
|
* Claude hooks in `~/.claude/settings.json` (Grok's Claude compatibility): it
|
||||||
|
* arrives with no `tool`, is told apart by the camelCase keys only Grok sends
|
||||||
|
* (`hookEventName`, `workspaceRoot`), and lands as a `tool = 'grok'` session
|
||||||
|
* on the grok workspace — never the claude one sharing the directory — with
|
||||||
|
* its `transcript_path` dropped. A Claude hook is left Claude's. The DB lives
|
||||||
|
* in a temp dir.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const path = require("node:path");
|
||||||
|
const os = require("node:os");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
|
||||||
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-grok-hook-route-"));
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||||
|
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||||
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||||
|
|
||||||
|
const { createApp, startServer } = require("../index");
|
||||||
|
const { db } = require("../db");
|
||||||
|
const lanes = require("../lib/lanes");
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
const CWD = path.join(fs.realpathSync(ROOT), "repo");
|
||||||
|
fs.mkdirSync(CWD, { recursive: true });
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await startServer(createApp(), 0);
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
after(() => {
|
||||||
|
server.close();
|
||||||
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = (body) =>
|
||||||
|
fetch(`${BASE}/api/hooks/event`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}).then((r) => r.json());
|
||||||
|
|
||||||
|
const grokData = (id, extra = {}) => ({
|
||||||
|
hookEventName: "session_start",
|
||||||
|
hook_event_name: "SessionStart",
|
||||||
|
sessionId: id,
|
||||||
|
session_id: id,
|
||||||
|
cwd: CWD,
|
||||||
|
workspaceRoot: CWD,
|
||||||
|
transcript_path: path.join(ROOT, "grok", "sessions", id, "chat_history.jsonl"),
|
||||||
|
permissionMode: "default",
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Grok hooks through the Claude settings", () => {
|
||||||
|
it("records a grok session, on the grok workspace, with no transcript path", async () => {
|
||||||
|
const claudeLane = lanes.createLane({ title: "c", cwd: CWD, tool: "claude" });
|
||||||
|
const grokLane = lanes.createLane({ title: "g", cwd: CWD, tool: "grok" });
|
||||||
|
const id = "0199f0aa-1111-7222-8333-444455556666";
|
||||||
|
await post({ hook_type: "SessionStart", data: grokData(id) });
|
||||||
|
await post({
|
||||||
|
hook_type: "UserPromptSubmit",
|
||||||
|
data: grokData(id, { hookEventName: "user_prompt_submit", prompt: "fix the bug" }),
|
||||||
|
});
|
||||||
|
const s = db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
|
||||||
|
assert.equal(s.tool, "grok");
|
||||||
|
assert.equal(s.transcript_path, null);
|
||||||
|
const events = db.prepare("SELECT data FROM events WHERE session_id = ?").all(id);
|
||||||
|
assert.ok(events.length >= 1);
|
||||||
|
for (const e of events) assert.equal(JSON.parse(e.data).transcript_path, undefined);
|
||||||
|
assert.equal(lanes.getLane(claudeLane.id).session_id, null);
|
||||||
|
assert.equal(lanes.getLane(grokLane.id).session_id, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a Claude hook Claude's", async () => {
|
||||||
|
const id = "0199f0aa-1111-7222-8333-777788889999";
|
||||||
|
await post({
|
||||||
|
hook_type: "SessionStart",
|
||||||
|
data: { hook_event_name: "SessionStart", session_id: id, cwd: CWD },
|
||||||
|
});
|
||||||
|
assert.equal(db.prepare("SELECT tool FROM sessions WHERE id = ?").get(id).tool, "claude");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -79,6 +79,8 @@ describe("File Explorer on the home", () => {
|
|||||||
r.body.homes.map((h) => [h.id, h.readOnly]),
|
r.body.homes.map((h) => [h.id, h.readOnly]),
|
||||||
[["me", false]]
|
[["me", false]]
|
||||||
);
|
);
|
||||||
|
// Its absolute folder: where the Files page's Clone repository clones to.
|
||||||
|
assert.equal(r.body.homes[0].dir, path.resolve(HOME));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists the home with every top-level dot entry hidden", async () => {
|
it("lists the home with every top-level dot entry hidden", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests server/lib/omp-usage.js and the omp branch of the transcript
|
||||||
|
* routes — Oh My Pi sessions from omp's session journals: a session created
|
||||||
|
* as `tool = 'omp'` with its title, cwd, model and per-model tokens (its
|
||||||
|
* subagents' included), a subagent row per subagent journal, a journal read on
|
||||||
|
* from where it stopped, `session_exit` settling it to completed, a session
|
||||||
|
* the user deleted staying deleted, a non-omp row with the same id never
|
||||||
|
* touched, and the journal mapped to the Claude transcript shape. The omp dir
|
||||||
|
* and the DB are temp dirs; the journals are synthetic, in the shape omp
|
||||||
|
* 18.12 writes.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const os = require("node:os");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-omp-usage-"));
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||||
|
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||||
|
process.env.AGENTHUB_OMP_DIR = path.join(ROOT, "omp");
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { db } = require("../db");
|
||||||
|
const omp = require("../lib/omp-usage");
|
||||||
|
const { createApp, startServer } = require("../index");
|
||||||
|
|
||||||
|
const ID = "01a15366-27e2-734a-9247-04d5c65c1636";
|
||||||
|
const STAMP = "2026-10-19T09-02-46-242Z";
|
||||||
|
const CWD = path.join(fs.realpathSync(ROOT), "work");
|
||||||
|
fs.mkdirSync(CWD, { recursive: true });
|
||||||
|
const DIR = path.join(process.env.AGENTHUB_OMP_DIR, "agent", "sessions", "-work");
|
||||||
|
const MAIN = path.join(DIR, `${STAMP}_${ID}.jsonl`);
|
||||||
|
const SUB = path.join(DIR, `${STAMP}_${ID}`, "Pinger.jsonl");
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
after(() => {
|
||||||
|
server?.close();
|
||||||
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const line = (o) => JSON.stringify(o) + "\n";
|
||||||
|
const title = (t) =>
|
||||||
|
line({ type: "title", v: 1, title: t, updatedAt: "2026-10-19T09:02:46.242Z", pad: " ".repeat(40) });
|
||||||
|
const header = (id = ID) =>
|
||||||
|
line({ type: "session", version: 3, id, timestamp: "2026-10-19T09:02:46.242Z", cwd: CWD });
|
||||||
|
const user = (text, attribution = "user") =>
|
||||||
|
line({
|
||||||
|
type: "message",
|
||||||
|
id: "u1",
|
||||||
|
timestamp: "2026-10-19T09:02:46.400Z",
|
||||||
|
message: { role: "user", content: [{ type: "text", text }], attribution },
|
||||||
|
});
|
||||||
|
const assistant = (content, usage, model = "claude-sonnet-4-5") =>
|
||||||
|
line({
|
||||||
|
type: "message",
|
||||||
|
id: "a1",
|
||||||
|
timestamp: "2026-10-19T09:02:47.000Z",
|
||||||
|
message: { role: "assistant", content, provider: "anthropic", model, usage },
|
||||||
|
});
|
||||||
|
const usage = (input, output, cacheRead = 0, cacheWrite = 0) => ({
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
cacheRead,
|
||||||
|
cacheWrite,
|
||||||
|
totalTokens: input + output + cacheRead + cacheWrite,
|
||||||
|
});
|
||||||
|
const exit = line({ type: "custom", customType: "session_exit", data: { reason: "dispose" } });
|
||||||
|
|
||||||
|
const sessionRow = () => db.prepare("SELECT * FROM sessions WHERE id = ?").get(ID);
|
||||||
|
const tokens = () =>
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
"SELECT model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens FROM token_usage WHERE session_id = ? ORDER BY model"
|
||||||
|
)
|
||||||
|
.all(ID);
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
fs.mkdirSync(path.dirname(SUB), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
MAIN,
|
||||||
|
title("") +
|
||||||
|
header() +
|
||||||
|
line({ type: "model_change", model: "anthropic/claude-sonnet-4-5" }) +
|
||||||
|
user("Fix the login bug\nin auth.ts") +
|
||||||
|
assistant(
|
||||||
|
[
|
||||||
|
{ type: "thinking", thinking: "Look first" },
|
||||||
|
{ type: "toolCall", id: "t1", name: "task", arguments: { tasks: [{ task: "ping" }] } },
|
||||||
|
],
|
||||||
|
usage(100, 20, 1000, 50)
|
||||||
|
) +
|
||||||
|
line({
|
||||||
|
type: "message",
|
||||||
|
timestamp: "2026-10-19T09:02:48.000Z",
|
||||||
|
message: {
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "t1",
|
||||||
|
toolName: "task",
|
||||||
|
content: [{ type: "text", text: "Spawned agent `Pinger`." }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
SUB,
|
||||||
|
header("01a15366-0000-7000-8000-000000000001") +
|
||||||
|
user("Complete assignment: ping", "agent") +
|
||||||
|
assistant([{ type: "text", text: "pong" }], usage(10, 5), "claude-haiku-4-5")
|
||||||
|
);
|
||||||
|
server = await startServer(createApp(), 0);
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("omp usage ingest", () => {
|
||||||
|
it("creates the session with its prompt, cwd, model and every journal's tokens", async () => {
|
||||||
|
const r = await omp.ingestOmpUsage();
|
||||||
|
assert.equal(r.files, 2);
|
||||||
|
const s = sessionRow();
|
||||||
|
assert.equal(s.tool, "omp");
|
||||||
|
assert.equal(s.name, "Fix the login bug");
|
||||||
|
assert.equal(s.cwd, CWD);
|
||||||
|
assert.equal(s.model, "claude-sonnet-4-5");
|
||||||
|
assert.equal(s.status, "active"); // written just now, no session_exit
|
||||||
|
assert.deepEqual(tokens(), [
|
||||||
|
{
|
||||||
|
model: "claude-haiku-4-5",
|
||||||
|
input_tokens: 10,
|
||||||
|
output_tokens: 5,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_write_tokens: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: "claude-sonnet-4-5",
|
||||||
|
input_tokens: 100,
|
||||||
|
output_tokens: 20,
|
||||||
|
cache_read_tokens: 1000,
|
||||||
|
cache_write_tokens: 50,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const agents = db.prepare("SELECT * FROM agents WHERE session_id = ?").all(ID);
|
||||||
|
assert.equal(agents.length, 1);
|
||||||
|
assert.equal(agents[0].id, `omp-${ID}-Pinger`);
|
||||||
|
assert.equal(agents[0].name, "Pinger");
|
||||||
|
assert.equal(agents[0].type, "subagent");
|
||||||
|
assert.equal(agents[0].task, "Complete assignment: ping");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an unchanged journal unread", async () => {
|
||||||
|
const r = await omp.ingestOmpUsage();
|
||||||
|
assert.equal(r.files, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads only what was appended, takes the title, and settles on session_exit", async () => {
|
||||||
|
fs.appendFileSync(MAIN, assistant([{ type: "text", text: "Done" }], usage(5, 7)) + exit);
|
||||||
|
// The title slot is rewritten in place, same width.
|
||||||
|
const text = fs.readFileSync(MAIN, "utf8").split("\n");
|
||||||
|
const slot = { type: "title", v: 1, title: "Login fix", updatedAt: "x", pad: "" };
|
||||||
|
slot.pad = " ".repeat(Math.max(0, text[0].length - JSON.stringify(slot).length));
|
||||||
|
text[0] = JSON.stringify(slot);
|
||||||
|
fs.writeFileSync(MAIN, text.join("\n"));
|
||||||
|
const r = await omp.ingestOmpUsage();
|
||||||
|
assert.equal(r.files, 1);
|
||||||
|
const s = sessionRow();
|
||||||
|
assert.equal(s.name, "Login fix");
|
||||||
|
assert.equal(s.status, "completed");
|
||||||
|
assert.ok(s.ended_at);
|
||||||
|
const sonnet = tokens().find((t) => t.model === "claude-sonnet-4-5");
|
||||||
|
assert.equal(sonnet.input_tokens, 105);
|
||||||
|
assert.equal(sonnet.output_tokens, 27);
|
||||||
|
const agent = db.prepare("SELECT status FROM agents WHERE id = ?").get(`omp-${ID}-Pinger`);
|
||||||
|
assert.equal(agent.status, "completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the journal to the transcript shape, and lists the subagent's", async () => {
|
||||||
|
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
|
||||||
|
assert.deepEqual(
|
||||||
|
list.transcripts.map((t) => [t.id, t.type, t.db_agent_id]),
|
||||||
|
[
|
||||||
|
["main", "main", null],
|
||||||
|
["Pinger", "subagent", `omp-${ID}-Pinger`],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
|
||||||
|
assert.deepEqual(
|
||||||
|
body.messages.map((m) => [m.type, m.sender, m.content.map((c) => c.type).join("+")]),
|
||||||
|
[
|
||||||
|
["user", "user", "text"],
|
||||||
|
["assistant", "assistant", "thinking+tool_use"],
|
||||||
|
["user", "tool", "tool_result"],
|
||||||
|
["assistant", "assistant", "text"],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.deepEqual(body.messages[1].content[1], {
|
||||||
|
type: "tool_use",
|
||||||
|
name: "task",
|
||||||
|
id: "t1",
|
||||||
|
input: { tasks: [{ task: "ping" }] },
|
||||||
|
});
|
||||||
|
const sub = await (
|
||||||
|
await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=Pinger`)
|
||||||
|
).json();
|
||||||
|
assert.deepEqual(
|
||||||
|
sub.messages.map((m) => [m.sender, m.content[0].text]),
|
||||||
|
[
|
||||||
|
["orchestrator", "Complete assignment: ping"],
|
||||||
|
["assistant", "pong"],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a deleted session deleted until omp writes again", async () => {
|
||||||
|
db.prepare("DELETE FROM sessions WHERE id = ?").run(ID);
|
||||||
|
await omp.ingestOmpUsage();
|
||||||
|
assert.equal(sessionRow(), undefined);
|
||||||
|
fs.appendFileSync(MAIN, user("one more"));
|
||||||
|
await omp.ingestOmpUsage();
|
||||||
|
assert.equal(sessionRow()?.tool, "omp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never touches another tool's session with the same id", async () => {
|
||||||
|
const other = "01a15366-27e2-734a-9247-04d5c65c9999";
|
||||||
|
db.prepare("INSERT INTO sessions (id, name, status, tool) VALUES (?, 'mine', 'completed', 'codex')").run(
|
||||||
|
other
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(DIR, `${STAMP}_${other}.jsonl`), title("") + header(other) + user("x"));
|
||||||
|
await omp.ingestOmpUsage();
|
||||||
|
const s = db.prepare("SELECT * FROM sessions WHERE id = ?").get(other);
|
||||||
|
assert.equal(s.tool, "codex");
|
||||||
|
assert.equal(s.name, "mine");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests server/lib/opencode-usage.js and the OpenCode branch of the
|
||||||
|
* transcript routes — OpenCode sessions from its SQLite store: a root session
|
||||||
|
* created as `tool = 'opencode'` with its title, cwd, model and per-model
|
||||||
|
* tokens (its subagent session's included, reasoning counted as output), a
|
||||||
|
* subagent row per child session, an unchanged store never re-read, a
|
||||||
|
* session the user deleted staying deleted until it is updated, and the
|
||||||
|
* conversation (text, reasoning, tool call + result) in the Claude
|
||||||
|
* transcript shape. The store is a temp file with the tables and columns
|
||||||
|
* opencode 1.19 writes.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const os = require("node:os");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-opencode-usage-"));
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
|
||||||
|
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||||
|
process.env.AGENTHUB_OPENCODE_DIR = path.join(ROOT, "opencode");
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { db, Database } = require("../db");
|
||||||
|
const oc = require("../lib/opencode-usage");
|
||||||
|
const { createApp, startServer } = require("../index");
|
||||||
|
|
||||||
|
const ID = "ses_eac8393b4ffeEz26VGbeyq8sir";
|
||||||
|
const CHILD = "ses_eac8393b4fffChildSession01";
|
||||||
|
const CWD = path.join(fs.realpathSync(ROOT), "work");
|
||||||
|
const STORE = path.join(process.env.AGENTHUB_OPENCODE_DIR, "opencode.db");
|
||||||
|
const T0 = Date.parse("2026-10-19T09:27:05.000Z");
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
after(() => {
|
||||||
|
server?.close();
|
||||||
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function withStore(fn) {
|
||||||
|
const conn = new Database(STORE);
|
||||||
|
try {
|
||||||
|
fn(conn);
|
||||||
|
} finally {
|
||||||
|
conn.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistant = (model, tokens) => JSON.stringify({ role: "assistant", modelID: model, tokens });
|
||||||
|
const tokens = (input, output, reasoning = 0, read = 0, write = 0) => ({
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
reasoning,
|
||||||
|
cache: { read, write },
|
||||||
|
});
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
fs.mkdirSync(path.dirname(STORE), { recursive: true });
|
||||||
|
withStore((c) => {
|
||||||
|
c.exec(`
|
||||||
|
CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT,
|
||||||
|
slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL, version TEXT NOT NULL,
|
||||||
|
model TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_archived INTEGER);
|
||||||
|
CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL,
|
||||||
|
time_updated INTEGER NOT NULL, data TEXT NOT NULL);
|
||||||
|
CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL,
|
||||||
|
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL);
|
||||||
|
`);
|
||||||
|
const s = c.prepare(
|
||||||
|
"INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, model, time_created, time_updated) VALUES (?, 'global', ?, 'x', ?, ?, '1.19.3', ?, ?, ?)"
|
||||||
|
);
|
||||||
|
s.run(ID, null, CWD, "Fix the login bug", '{"id":"claude-sonnet-4-5","providerID":"anthropic"}', T0, T0 + 5000);
|
||||||
|
s.run(CHILD, ID, CWD, "Explore auth (@explore subagent)", null, T0 + 1000, T0 + 3000);
|
||||||
|
const m = c.prepare("INSERT INTO message VALUES (?, ?, ?, ?, ?)");
|
||||||
|
m.run("msg_1", ID, T0 + 10, T0 + 10, JSON.stringify({ role: "user" }));
|
||||||
|
m.run("msg_2", ID, T0 + 20, T0 + 20, assistant("claude-sonnet-4-5", tokens(100, 20, 5, 1000, 50)));
|
||||||
|
m.run("msg_3", CHILD, T0 + 1100, T0 + 1100, JSON.stringify({ role: "user" }));
|
||||||
|
m.run("msg_4", CHILD, T0 + 1200, T0 + 1200, assistant("claude-haiku-4-5", tokens(10, 4)));
|
||||||
|
const p = c.prepare("INSERT INTO part VALUES (?, ?, ?, ?, ?, ?)");
|
||||||
|
p.run("prt_1", "msg_1", ID, T0 + 11, T0 + 11, JSON.stringify({ type: "text", text: "Fix the login bug" }));
|
||||||
|
p.run("prt_2", "msg_2", ID, T0 + 21, T0 + 21, JSON.stringify({ type: "reasoning", text: "Look first" }));
|
||||||
|
p.run(
|
||||||
|
"prt_3",
|
||||||
|
"msg_2",
|
||||||
|
ID,
|
||||||
|
T0 + 22,
|
||||||
|
T0 + 22,
|
||||||
|
JSON.stringify({
|
||||||
|
type: "tool",
|
||||||
|
tool: "read",
|
||||||
|
callID: "call_1",
|
||||||
|
state: { status: "completed", input: { filePath: "auth.ts" }, output: "export {}" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
p.run("prt_4", "msg_2", ID, T0 + 23, T0 + 23, JSON.stringify({ type: "step-start" }));
|
||||||
|
p.run("prt_5", "msg_3", CHILD, T0 + 1101, T0 + 1101, JSON.stringify({ type: "text", text: "Explore auth" }));
|
||||||
|
p.run("prt_6", "msg_4", CHILD, T0 + 1201, T0 + 1201, JSON.stringify({ type: "text", text: "Found it" }));
|
||||||
|
});
|
||||||
|
server = await startServer(createApp(), 0);
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionRow = () => db.prepare("SELECT * FROM sessions WHERE id = ?").get(ID);
|
||||||
|
|
||||||
|
describe("opencode usage ingest", () => {
|
||||||
|
it("creates the root session with its title, cwd, model and its tree's tokens", async () => {
|
||||||
|
const r = await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
|
||||||
|
assert.equal(r.files, 1);
|
||||||
|
const s = sessionRow();
|
||||||
|
assert.equal(s.tool, "opencode");
|
||||||
|
assert.equal(s.name, "Fix the login bug");
|
||||||
|
assert.equal(s.cwd, CWD);
|
||||||
|
assert.equal(s.model, "claude-sonnet-4-5");
|
||||||
|
assert.equal(s.status, "completed");
|
||||||
|
assert.equal(db.prepare("SELECT 1 FROM sessions WHERE id = ?").get(CHILD), undefined);
|
||||||
|
const usage = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens FROM token_usage WHERE session_id = ? ORDER BY model"
|
||||||
|
)
|
||||||
|
.all(ID);
|
||||||
|
assert.deepEqual(usage, [
|
||||||
|
{ model: "claude-haiku-4-5", input_tokens: 10, output_tokens: 4, cache_read_tokens: 0, cache_write_tokens: 0 },
|
||||||
|
{ model: "claude-sonnet-4-5", input_tokens: 100, output_tokens: 25, cache_read_tokens: 1000, cache_write_tokens: 50 },
|
||||||
|
]);
|
||||||
|
const agent = db.prepare("SELECT * FROM agents WHERE id = ?").get(`opencode-${CHILD}`);
|
||||||
|
assert.equal(agent.session_id, ID);
|
||||||
|
assert.equal(agent.type, "subagent");
|
||||||
|
assert.equal(agent.name, "Explore auth (@explore subagent)");
|
||||||
|
assert.equal(agent.status, "completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an unchanged store unread", async () => {
|
||||||
|
const r = await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
|
||||||
|
assert.equal(r.files, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the store to the transcript shape, and lists the subagent's", async () => {
|
||||||
|
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
|
||||||
|
assert.deepEqual(
|
||||||
|
list.transcripts.map((t) => [t.id, t.type, t.db_agent_id]),
|
||||||
|
[
|
||||||
|
["main", "main", null],
|
||||||
|
[CHILD, "subagent", `opencode-${CHILD}`],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
|
||||||
|
assert.deepEqual(
|
||||||
|
body.messages.map((m) => [m.type, m.sender, m.content.map((c) => c.type).join("+")]),
|
||||||
|
[
|
||||||
|
["user", "user", "text"],
|
||||||
|
["assistant", "assistant", "thinking+tool_use"],
|
||||||
|
["user", "tool", "tool_result"],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.equal(body.messages[2].content[0].output, "export {}");
|
||||||
|
assert.equal(body.last_line, 3);
|
||||||
|
const tail = await (await fetch(`${BASE}/api/sessions/${ID}/transcript?after=2`)).json();
|
||||||
|
assert.equal(tail.messages.length, 1);
|
||||||
|
const sub = await (
|
||||||
|
await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=${CHILD}`)
|
||||||
|
).json();
|
||||||
|
assert.deepEqual(
|
||||||
|
sub.messages.map((m) => [m.sender, m.content[0].text]),
|
||||||
|
[
|
||||||
|
["orchestrator", "Explore auth"],
|
||||||
|
["assistant", "Found it"],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a deleted session deleted until OpenCode updates it", async () => {
|
||||||
|
db.prepare("DELETE FROM sessions WHERE id = ?").run(ID);
|
||||||
|
const bump = (t) => {
|
||||||
|
withStore((c) => c.prepare("UPDATE session SET title = title WHERE id = ?").run(ID));
|
||||||
|
const at = new Date(t);
|
||||||
|
fs.utimesSync(STORE, at, at);
|
||||||
|
};
|
||||||
|
bump(Date.now() + 5000);
|
||||||
|
await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
|
||||||
|
assert.equal(sessionRow(), undefined);
|
||||||
|
withStore((c) => c.prepare("UPDATE session SET time_updated = ? WHERE id = ?").run(T0 + 9000, ID));
|
||||||
|
fs.utimesSync(STORE, new Date(Date.now() + 10_000), new Date(Date.now() + 10_000));
|
||||||
|
await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
|
||||||
|
assert.equal(sessionRow()?.tool, "opencode");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -62,6 +62,37 @@ describe("pty-attach", () => {
|
|||||||
assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-0"), /EBADRUNID/);
|
assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-0"), /EBADRUNID/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves copy-mode before the first keystroke after a wheel, and only then", () => {
|
||||||
|
const calls = [];
|
||||||
|
tmux.__setExecImpl((args) => {
|
||||||
|
calls.push(args);
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
const ws = makeFakeWs();
|
||||||
|
ptyAttach.attach(ws, "agenthub-lane-1", { cols: 80, rows: 24 });
|
||||||
|
const leaves = () => calls.filter((c) => c[0] === "copy-mode").length;
|
||||||
|
const send = (t) => ws.__emitter.emit("message", Buffer.from(t), false);
|
||||||
|
|
||||||
|
send("a");
|
||||||
|
assert.equal(leaves(), 0, "no scroll yet: typing spawns no tmux");
|
||||||
|
send("\x1b[<64;10;5M\x1b[<64;10;5M");
|
||||||
|
send("\x1b[I");
|
||||||
|
assert.equal(leaves(), 0, "a wheel or a focus report is not typing");
|
||||||
|
send("b");
|
||||||
|
assert.deepEqual(calls.filter((c) => c[0] === "copy-mode")[0], [
|
||||||
|
"copy-mode",
|
||||||
|
"-q",
|
||||||
|
"-t",
|
||||||
|
"=agenthub-lane-1:",
|
||||||
|
]);
|
||||||
|
send("c");
|
||||||
|
assert.equal(leaves(), 1, "once per scroll");
|
||||||
|
assert.deepEqual(
|
||||||
|
fakePty.__writes.filter((w) => typeof w === "string"),
|
||||||
|
["a", "\x1b[<64;10;5M\x1b[<64;10;5M", "\x1b[I", "b", "c"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("turns tmux mouse mode on for the session it attaches to", () => {
|
it("turns tmux mouse mode on for the session it attaches to", () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
tmux.__setExecImpl((args) => {
|
tmux.__setExecImpl((args) => {
|
||||||
|
|||||||
@@ -733,7 +733,7 @@ describe("pty-run", () => {
|
|||||||
let savedPath;
|
let savedPath;
|
||||||
before(() => {
|
before(() => {
|
||||||
fs.mkdirSync(BIN, { recursive: true });
|
fs.mkdirSync(BIN, { recursive: true });
|
||||||
for (const name of ["agy", "grok", "goose", "command-code"]) {
|
for (const name of ["agy", "grok", "goose", "command-code", "omp"]) {
|
||||||
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
|
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
|
||||||
fs.chmodSync(path.join(BIN, name), 0o755);
|
fs.chmodSync(path.join(BIN, name), 0o755);
|
||||||
}
|
}
|
||||||
@@ -799,6 +799,35 @@ describe("pty-run", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// omp declares `resumeById`: the id follows its flag as one argv element,
|
||||||
|
// in place of the resume-last args, and never one that reads as a flag.
|
||||||
|
it("resumes a named session for a catalog tool that declares resumeById", () => {
|
||||||
|
const call = captureNewSession(() =>
|
||||||
|
pty.spawnRun({
|
||||||
|
laneId: 17,
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
tool: "omp",
|
||||||
|
model: "opus",
|
||||||
|
resumeSessionId: "01a15366-27e2-734a-9247-04d5c65c1636",
|
||||||
|
resumeLast: true,
|
||||||
|
initialPrompt: "go on",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert.deepEqual(paneArgv(call), [
|
||||||
|
at("omp"),
|
||||||
|
"--model",
|
||||||
|
"opus",
|
||||||
|
"--resume",
|
||||||
|
"01a15366-27e2-734a-9247-04d5c65c1636",
|
||||||
|
"go on",
|
||||||
|
]);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
pty.spawnRun({ laneId: 18, cwd: "/tmp/repo", tool: "omp", resumeSessionId: "--abcdefgh" }),
|
||||||
|
{ code: "EBADSESSION" }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses a flag-shaped model id for any tool, before tmux is asked", () => {
|
it("refuses a flag-shaped model id for any tool, before tmux is asked", () => {
|
||||||
for (const tool of ["claude", "codex", "antigravity"]) {
|
for (const tool of ["claude", "codex", "antigravity"]) {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
|
|||||||
@@ -183,9 +183,18 @@ describe("tmux wrapper", () => {
|
|||||||
calls = fake(true);
|
calls = fake(true);
|
||||||
tmux.enableMouse("agenthub-lane-1");
|
tmux.enableMouse("agenthub-lane-1");
|
||||||
assert.ok(calls.some((c) => c[0] === "set-option" && c[2] === "=agenthub-lane-1:"));
|
assert.ok(calls.some((c) => c[0] === "set-option" && c[2] === "=agenthub-lane-1:"));
|
||||||
// tmux chains the four options into two spawns; psmux above got four.
|
// tmux chains the four options into two spawns, plus the copy-mode
|
||||||
|
// position box (a newer tmux only) in its own; psmux above got five.
|
||||||
const sets = calls.filter((c) => c[0] === "set-option");
|
const sets = calls.filter((c) => c[0] === "set-option");
|
||||||
assert.equal(sets.length, 2);
|
assert.equal(sets.length, 3);
|
||||||
|
assert.deepEqual(sets[2], [
|
||||||
|
"set-option",
|
||||||
|
"-w",
|
||||||
|
"-t",
|
||||||
|
"=agenthub-lane-1:",
|
||||||
|
"copy-mode-position-format",
|
||||||
|
"",
|
||||||
|
]);
|
||||||
assert.deepEqual(sets[0], [
|
assert.deepEqual(sets[0], [
|
||||||
"set-option",
|
"set-option",
|
||||||
"-t",
|
"-t",
|
||||||
@@ -235,7 +244,39 @@ describe("tmux wrapper", () => {
|
|||||||
...["set-option", "-t", "=agenthub-lane-1:", "pane-scrollbars-style"],
|
...["set-option", "-t", "=agenthub-lane-1:", "pane-scrollbars-style"],
|
||||||
"fg=brightwhite,bg=black,width=1,pad=0",
|
"fg=brightwhite,bg=black,width=1,pad=0",
|
||||||
]);
|
]);
|
||||||
assert.equal(calls.length, 3);
|
// No yellow `[0/0]` box in the corner while the wheel's copy-mode is on.
|
||||||
|
assert.deepEqual(calls[3], [
|
||||||
|
...["set-option", "-w", "-t", "=agenthub-lane-1:", "copy-mode-position-format", ""],
|
||||||
|
]);
|
||||||
|
// Copy-mode keys that open a prompt ("(goto line)"…) leave copy-mode and
|
||||||
|
// reach the program in OUR sessions only; others keep tmux's default.
|
||||||
|
assert.deepEqual(calls[4].slice(0, 9), [
|
||||||
|
...["bind-key", "-T", "copy-mode", "g", "if-shell", "-F"],
|
||||||
|
"#{m:agenthub-lane-*,#{session_name}}",
|
||||||
|
"send-keys -X cancel ; send-keys -l -- 'g'",
|
||||||
|
'command-prompt -P -p "(goto line)" { send-keys -X goto-line -- "%%" }',
|
||||||
|
]);
|
||||||
|
assert.ok(calls[4].includes(":"), "vi's `:` goto-line is re-bound too");
|
||||||
|
// The wheel scrolls 10 lines a notch in OUR panes, 5 (tmux's) elsewhere.
|
||||||
|
assert.deepEqual(calls[5].slice(0, 9), [
|
||||||
|
...["bind-key", "-T", "root", "WheelUpPane", "if-shell", "-F"],
|
||||||
|
"#{||:#{alternate_on},#{pane_in_mode},#{mouse_any_flag}}",
|
||||||
|
"send-keys -M",
|
||||||
|
'if-shell -F "#{m:agenthub-lane-*,#{session_name}}" { copy-mode -e ; send-keys -X -N 10 scroll-up } { copy-mode -e }',
|
||||||
|
]);
|
||||||
|
assert.ok(calls[5].includes("select-pane ; send-keys -X -N 10 scroll-down"));
|
||||||
|
assert.ok(calls[5].includes("select-pane ; send-keys -X -N 5 scroll-down"));
|
||||||
|
assert.equal(calls.length, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("newSession still succeeds when tmux refuses the copy-mode bindings", () => {
|
||||||
|
tmux.__setExecImpl((args) => {
|
||||||
|
if (args[0] === "bind-key") throw new Error("unknown flag -F");
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
tmux.newSession({ name: "agenthub-lane-1", cwd: "/tmp/repo", argv: ["claude"] })
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("newSession still succeeds on a tmux older than 3.7 (no pane-scrollbars)", () => {
|
it("newSession still succeeds on a tmux older than 3.7 (no pane-scrollbars)", () => {
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ One JSON file per AI coding CLI AgentHub can run in a lane. Loaded by
|
|||||||
`DASHBOARD_AI_TOOLS_DIR` with the same `id` replaces the built-in one (it may
|
`DASHBOARD_AI_TOOLS_DIR` with the same `id` replaces the built-in one (it may
|
||||||
not set `launcher`).
|
not set `launcher`).
|
||||||
|
|
||||||
|
## Resume by id
|
||||||
|
|
||||||
|
`resumeById` (string array, e.g. `["--resume"]`): the flag(s) a NAMED session's
|
||||||
|
id follows, as one more argv element — `pty-run.js` emits them in place of
|
||||||
|
`resume` when a start carries `resumeSessionId`, and only for an id that starts
|
||||||
|
with a letter or digit. A tool without it (and without a `launcher`) refuses a
|
||||||
|
resume by id (`EUNSUPPORTED`) rather than start fresh under a "resume".
|
||||||
|
Built-in: omp, Grok and Cursor (`--resume`), OpenCode (`--session`). A session id may carry `_` (OpenCode's `ses_…`).
|
||||||
|
|
||||||
## Install / update (A2)
|
## Install / update (A2)
|
||||||
|
|
||||||
Two optional fields, honoured only in built-in files and in the copy of the
|
Two optional fields, honoured only in built-in files and in the copy of the
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "copilot",
|
|
||||||
"label": "GitHub Copilot",
|
|
||||||
"detect": [
|
|
||||||
"copilot"
|
|
||||||
],
|
|
||||||
"prompt": {
|
|
||||||
"mode": "flag",
|
|
||||||
"flag": "-i"
|
|
||||||
},
|
|
||||||
"bypass": [
|
|
||||||
"--yolo"
|
|
||||||
],
|
|
||||||
"homepageUrl": "https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli",
|
|
||||||
"install": {
|
|
||||||
"kind": "npm",
|
|
||||||
"package": "@github/copilot"
|
|
||||||
},
|
|
||||||
"update": {
|
|
||||||
"kind": "npm"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,9 @@
|
|||||||
"resume": [
|
"resume": [
|
||||||
"--continue"
|
"--continue"
|
||||||
],
|
],
|
||||||
|
"resumeById": [
|
||||||
|
"--resume"
|
||||||
|
],
|
||||||
"bypass": [
|
"bypass": [
|
||||||
"--yolo"
|
"--yolo"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
"resume": [
|
"resume": [
|
||||||
"--continue"
|
"--continue"
|
||||||
],
|
],
|
||||||
|
"resumeById": [
|
||||||
|
"--resume"
|
||||||
|
],
|
||||||
"bypass": [
|
"bypass": [
|
||||||
"--permission-mode",
|
"--permission-mode",
|
||||||
"bypassPermissions"
|
"bypassPermissions"
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "kimi",
|
|
||||||
"label": "Kimi Code",
|
|
||||||
"detect": [
|
|
||||||
"kimi"
|
|
||||||
],
|
|
||||||
"prompt": {
|
|
||||||
"mode": "after-start"
|
|
||||||
},
|
|
||||||
"model": {
|
|
||||||
"flag": "--model"
|
|
||||||
},
|
|
||||||
"resume": [
|
|
||||||
"--continue"
|
|
||||||
],
|
|
||||||
"bypass": [
|
|
||||||
"--auto"
|
|
||||||
],
|
|
||||||
"homepageUrl": "https://www.kimi.com/code/docs/",
|
|
||||||
"faviconDomain": "kimi.com",
|
|
||||||
"install": {
|
|
||||||
"kind": "script",
|
|
||||||
"url": "https://code.kimi.com/kimi-code/install.sh"
|
|
||||||
},
|
|
||||||
"update": {
|
|
||||||
"kind": "self",
|
|
||||||
"args": [
|
|
||||||
"upgrade",
|
|
||||||
"-y"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"config": [
|
|
||||||
{
|
|
||||||
"path": "~/.kimi-code/config.toml"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "~/.kimi-code/tui.toml"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -7,12 +7,35 @@
|
|||||||
"prompt": {
|
"prompt": {
|
||||||
"mode": "argv"
|
"mode": "argv"
|
||||||
},
|
},
|
||||||
|
"model": {
|
||||||
|
"flag": "--model"
|
||||||
|
},
|
||||||
|
"resume": [
|
||||||
|
"--continue"
|
||||||
|
],
|
||||||
|
"resumeById": [
|
||||||
|
"--resume"
|
||||||
|
],
|
||||||
|
"bypass": [
|
||||||
|
"--auto-approve"
|
||||||
|
],
|
||||||
"homepageUrl": "https://omp.sh",
|
"homepageUrl": "https://omp.sh",
|
||||||
|
"faviconDomain": "omp.sh",
|
||||||
"install": {
|
"install": {
|
||||||
"kind": "script",
|
"kind": "script",
|
||||||
"url": "https://omp.sh/install"
|
"url": "https://omp.sh/install"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"kind": "script"
|
"kind": "script"
|
||||||
|
},
|
||||||
|
"config": [
|
||||||
|
{
|
||||||
|
"path": "~/.omp/agent/config.yml",
|
||||||
|
"format": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "~/.omp/agent/models.yml",
|
||||||
|
"format": "text"
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
"model": {
|
"model": {
|
||||||
"flag": "--model"
|
"flag": "--model"
|
||||||
},
|
},
|
||||||
|
"resume": [
|
||||||
|
"--continue"
|
||||||
|
],
|
||||||
|
"resumeById": [
|
||||||
|
"--session"
|
||||||
|
],
|
||||||
"homepageUrl": "https://opencode.ai/docs/cli/",
|
"homepageUrl": "https://opencode.ai/docs/cli/",
|
||||||
"install": {
|
"install": {
|
||||||
"kind": "npm",
|
"kind": "npm",
|
||||||
|
|||||||
@@ -1429,6 +1429,41 @@ db.exec(`
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Which Oh My Pi session journals have been read, how far, and what they said
|
||||||
|
// so far (server/lib/omp-usage.js) — journals only grow, so each is read on
|
||||||
|
// from `offset`; the same "deleted stays deleted" rule.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS omp_sessions (
|
||||||
|
path TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
agent TEXT NOT NULL DEFAULT '',
|
||||||
|
size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
mtime_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
offset INTEGER NOT NULL DEFAULT 0,
|
||||||
|
state TEXT NOT NULL DEFAULT '{}',
|
||||||
|
title TEXT,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_omp_sessions_session ON omp_sessions(session_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Which OpenCode stores have been read at what mtime, and per root session the
|
||||||
|
// newest `time_updated` written (server/lib/opencode-usage.js) — the same
|
||||||
|
// "deleted stays deleted" rule.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS opencode_stores (
|
||||||
|
path TEXT PRIMARY KEY,
|
||||||
|
mtime_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS opencode_sessions (
|
||||||
|
session_id TEXT PRIMARY KEY,
|
||||||
|
store TEXT NOT NULL,
|
||||||
|
time_updated INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
// Which Cursor agent transcripts have been read, at what mtime
|
// Which Cursor agent transcripts have been read, at what mtime
|
||||||
// (server/lib/cursor-usage.js) — the same "deleted stays deleted" rule.
|
// (server/lib/cursor-usage.js) — the same "deleted stays deleted" rule.
|
||||||
db.exec(`
|
db.exec(`
|
||||||
|
|||||||
@@ -766,6 +766,18 @@ function startBackgroundServices() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("cursor usage poll failed to start:", err.message);
|
console.warn("cursor usage poll failed to start:", err.message);
|
||||||
}
|
}
|
||||||
|
// Oh My Pi and OpenCode sessions, subagents and token usage, from each CLI's
|
||||||
|
// own session store (omp-usage.js, opencode-usage.js).
|
||||||
|
for (const [name, mod, start] of [
|
||||||
|
["omp", "./lib/omp-usage", "startOmpUsagePoll"],
|
||||||
|
["opencode", "./lib/opencode-usage", "startOpencodeUsagePoll"],
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
require(mod)[start](broadcast);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`${name} usage poll failed to start:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Codex/Antigravity hooks the user installed forward through a script whose
|
// Codex/Antigravity hooks the user installed forward through a script whose
|
||||||
// content names this build; point it here (the command in the tool's
|
// content names this build; point it here (the command in the tool's
|
||||||
// hooks.json, which Codex trusts, stays the same).
|
// hooks.json, which Codex trusts, stays the same).
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function validate(doc, source) {
|
|||||||
if (typeof doc.label !== "string" || !doc.label.trim()) return null;
|
if (typeof doc.label !== "string" || !doc.label.trim()) return null;
|
||||||
if (!isStrArray(doc.detect, CMD_RE) || !doc.detect.length) return null;
|
if (!isStrArray(doc.detect, CMD_RE) || !doc.detect.length) return null;
|
||||||
if (doc.requires !== undefined && !isStrArray(doc.requires, CMD_RE)) return null;
|
if (doc.requires !== undefined && !isStrArray(doc.requires, CMD_RE)) return null;
|
||||||
for (const k of ["args", "resume", "bypass"]) {
|
for (const k of ["args", "resume", "resumeById", "bypass"]) {
|
||||||
if (doc[k] !== undefined && !isStrArray(doc[k])) return null;
|
if (doc[k] !== undefined && !isStrArray(doc[k])) return null;
|
||||||
}
|
}
|
||||||
if (doc.bypassEnv !== undefined) {
|
if (doc.bypassEnv !== undefined) {
|
||||||
@@ -145,6 +145,9 @@ function validate(doc, source) {
|
|||||||
faviconDomain: typeof doc.faviconDomain === "string" ? doc.faviconDomain : null,
|
faviconDomain: typeof doc.faviconDomain === "string" ? doc.faviconDomain : null,
|
||||||
prompt,
|
prompt,
|
||||||
resume: doc.resume || null,
|
resume: doc.resume || null,
|
||||||
|
// `resumeById`: the flag(s) a NAMED session's id follows, as one more argv
|
||||||
|
// element (`omp --resume <id>`) — the id is validated at spawn time.
|
||||||
|
resumeById: doc.resumeById || null,
|
||||||
bypass: doc.bypass || null,
|
bypass: doc.bypass || null,
|
||||||
bypassEnv: doc.bypassEnv || null,
|
bypassEnv: doc.bypassEnv || null,
|
||||||
launcher,
|
launcher,
|
||||||
@@ -416,6 +419,8 @@ function view() {
|
|||||||
launcher: t.launcher,
|
launcher: t.launcher,
|
||||||
prompt: t.prompt ? { mode: t.prompt.mode } : null,
|
prompt: t.prompt ? { mode: t.prompt.mode } : null,
|
||||||
resume: !!t.resume,
|
resume: !!t.resume,
|
||||||
|
// claude/codex resume a named session through their own argv builders.
|
||||||
|
resumeById: Boolean(t.resumeById || t.launcher),
|
||||||
bypass: !!(t.bypass || t.bypassEnv),
|
bypass: !!(t.bypass || t.bypassEnv),
|
||||||
proxyCapable: PROXY_CAPABLE.has(t.id),
|
proxyCapable: PROXY_CAPABLE.has(t.id),
|
||||||
// claude/codex take a model through their own argv builders.
|
// claude/codex take a model through their own argv builders.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -44,8 +44,8 @@ const gitCredentials = require("./git-credentials");
|
|||||||
const runAs = require("./run-as");
|
const runAs = require("./run-as");
|
||||||
|
|
||||||
const HOST_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?$/;
|
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. */
|
/** A clone may take a while on a big repo (~1 GB); past this it is killed. */
|
||||||
const CLONE_TIMEOUT_MS = 10 * 60_000;
|
const CLONE_TIMEOUT_MS = 30 * 60_000;
|
||||||
const HELPER = path.join(__dirname, "git-credential-helper.js");
|
const HELPER = path.join(__dirname, "git-credential-helper.js");
|
||||||
|
|
||||||
const err = (code, message) => Object.assign(new Error(message), { code });
|
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
|
* Clone `url` into `<parent>/<name>` as the account owning `parent`, with
|
||||||
* that account's own identity. `once` is a login typed for this clone
|
* that account's own identity. `once` is a login typed for this clone
|
||||||
* (`{username, password, remember}`) — https only, for this URL's host only.
|
* (`{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}`
|
* Resolves `{dir, account}`; rejects with `ECREDENTIALS` (`{protocol, host}`
|
||||||
* attached) when git wanted a login it did not get, `ESSHAUTH` when the key
|
* attached) when git wanted a login it did not get, `ESSHAUTH` when the key
|
||||||
* was refused, else the git error.
|
* 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 { git } = require("./worktree");
|
||||||
const account = accountForDir(parent);
|
const account = accountForDir(parent);
|
||||||
const remote = remoteOf(url);
|
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 persist = [];
|
||||||
const env = {};
|
const env = {};
|
||||||
let restore = null;
|
let restore = null;
|
||||||
@@ -911,9 +929,13 @@ async function cloneRepo({ url, parent, name, once = null }) {
|
|||||||
|
|
||||||
let ok = false;
|
let ok = false;
|
||||||
try {
|
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,
|
timeout: CLONE_TIMEOUT_MS,
|
||||||
env,
|
env,
|
||||||
|
background: true,
|
||||||
|
onStderr: onProgress,
|
||||||
});
|
});
|
||||||
ok = true;
|
ok = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -932,9 +954,7 @@ async function cloneRepo({ url, parent, name, once = null }) {
|
|||||||
}
|
}
|
||||||
throw err(
|
throw err(
|
||||||
"ECLONE",
|
"ECLONE",
|
||||||
String(stderr || e.message)
|
(require("./clone-queue").stripProgress(stderr) || e.message).trim().slice(-800)
|
||||||
.trim()
|
|
||||||
.slice(-800)
|
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
// A login typed without "remember" — or one that did not work — leaves
|
// A login typed without "remember" — or one that did not work — leaves
|
||||||
|
|||||||
+12
-3
@@ -49,14 +49,23 @@ function homeOf(user) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The homes `req` may open: `{ id: "me" | <username>, label, readOnly }`.
|
* The homes `req` may open: `{ id: "me" | <username>, label, readOnly }`,
|
||||||
|
* `me` with its absolute `dir`.
|
||||||
* Others' only for an admin, and only those that resolve to a folder.
|
* Others' only for an admin, and only those that resolve to a folder.
|
||||||
*/
|
*/
|
||||||
function listHomes(req) {
|
function listHomes(req) {
|
||||||
const out = [];
|
const out = [];
|
||||||
const me = req.user || null;
|
const me = req.user || null;
|
||||||
if (homeOf(me))
|
const mine = homeOf(me);
|
||||||
out.push({ id: "me", label: me?.displayName || me?.username || "", readOnly: false });
|
// `dir` for the caller's own home only: the Files page clones into it.
|
||||||
|
if (mine) {
|
||||||
|
out.push({
|
||||||
|
id: "me",
|
||||||
|
label: me?.displayName || me?.username || "",
|
||||||
|
readOnly: false,
|
||||||
|
dir: path.resolve(mine),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (me?.role === "admin" && auth.isServerMode()) {
|
if (me?.role === "admin" && auth.isServerMode()) {
|
||||||
for (const u of auth.listUsers()) {
|
for (const u of auth.listUsers()) {
|
||||||
if (u.id === me.id || u.disabled) continue;
|
if (u.id === me.id || u.disabled) continue;
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* @file Reads what an append-only JSONL journal gained since a byte offset —
|
||||||
|
* the incremental read a session-journal poller does (omp-usage.js). Only
|
||||||
|
* complete lines are returned: one still being written
|
||||||
|
* stays for the next pass. Every open goes through `openSafeAsync`, so a
|
||||||
|
* member's journal (S3) is read only when it is a regular file they own,
|
||||||
|
* never through a link.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CHUNK = 8 * 1024 * 1024; // read per file per pass; the rest waits for the next
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The complete lines written between `offset` and `size`: `{lines, offset}`.
|
||||||
|
* A chunk with no newline at all (one line over CHUNK) is skipped whole.
|
||||||
|
*/
|
||||||
|
async function readNew(file, offset, size, { chunk = CHUNK } = {}) {
|
||||||
|
const fh = await require("./run-as").openSafeAsync(file);
|
||||||
|
try {
|
||||||
|
const len = Math.min(size - offset, chunk);
|
||||||
|
if (len <= 0) return { lines: [], offset };
|
||||||
|
const buf = Buffer.alloc(len);
|
||||||
|
let got = 0;
|
||||||
|
for (let n; got < len; got += n) {
|
||||||
|
({ bytesRead: n } = await fh.read(buf, got, len - got, offset + got));
|
||||||
|
if (!n) break;
|
||||||
|
}
|
||||||
|
const end = got ? buf.lastIndexOf(0x0a, got - 1) : -1;
|
||||||
|
if (end === -1) return { lines: [], offset: got === chunk ? offset + got : offset };
|
||||||
|
return { lines: buf.toString("utf8", 0, end).split("\n"), offset: offset + end + 1 };
|
||||||
|
} finally {
|
||||||
|
await fh.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { readNew };
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
/**
|
||||||
|
* @file Oh My Pi (`omp`) sessions, subagents and token usage, read from the
|
||||||
|
* session journals omp writes under `~/.omp/agent/sessions/<encoded cwd>/`.
|
||||||
|
* This is how an omp session reaches `sessions` (as `tool = 'omp'`), its
|
||||||
|
* tokens `token_usage` and its subagents `agents` — omp has no hooks.
|
||||||
|
*
|
||||||
|
* What a journal holds (omp 18.x, verified against files it wrote — not a
|
||||||
|
* documented format):
|
||||||
|
* - its file name is `<timestamp>_<session id>.jsonl`, and `omp --resume <id>`
|
||||||
|
* takes that id back;
|
||||||
|
* - line 1 is a fixed-width `{"type":"title", title}` slot omp rewrites in
|
||||||
|
* place, line 2 the `{"type":"session", id, cwd, timestamp}` header;
|
||||||
|
* - every assistant turn is a `{"type":"message"}` whose `message.usage` is
|
||||||
|
* `{input, output, cacheRead, cacheWrite}` (input NOT including cache reads)
|
||||||
|
* under `message.model`;
|
||||||
|
* - a process that ends appends `{"type":"custom","customType":"session_exit"}`;
|
||||||
|
* - a subagent the `task` tool spawns writes its own journal, in the same
|
||||||
|
* format, under the folder named like the session file
|
||||||
|
* (`<timestamp>_<session id>/<Agent name>.jsonl`, nested agents deeper).
|
||||||
|
* Journals only grow (bar the title slot), so each is read from where the last
|
||||||
|
* pass stopped; what it said so far is kept per file in `omp_sessions`, so a
|
||||||
|
* session the user deleted stays deleted until omp writes to it again. A
|
||||||
|
* session's tokens are the sum of its own journal and its subagents'.
|
||||||
|
*
|
||||||
|
* Status: completed once its process wrote `session_exit`, else active while
|
||||||
|
* a journal changed in the last 2 minutes or a live dashboard pane holds the
|
||||||
|
* session (an idle omp waiting for its user writes nothing), else completed.
|
||||||
|
* S3: every member's own `~/.omp` is read too, only through `openSafeAsync`
|
||||||
|
* (never a link, only a file that member owns), and only for a session whose
|
||||||
|
* folder is that same member's.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { db, stmts } = require("../db");
|
||||||
|
const { claimToolSessions } = require("./dashboard-runs");
|
||||||
|
const pricingSync = require("./pricing-sync");
|
||||||
|
const { readNew } = require("./jsonl-tail");
|
||||||
|
|
||||||
|
const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active
|
||||||
|
const FUTURE_SLACK_MS = 1000;
|
||||||
|
const SESSION_FILE = /^.+_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/;
|
||||||
|
const SESSION_DIR = /^.+_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
|
||||||
|
const HEAD_BYTES = 4096;
|
||||||
|
const MAX_DEPTH = 4; // nested subagents
|
||||||
|
const MAX_FILES = 20_000;
|
||||||
|
|
||||||
|
/** `~/.omp`, or `AGENTHUB_OMP_DIR`; read at call time. */
|
||||||
|
function getOmpDir() {
|
||||||
|
return process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A subagent's key: its path under the session folder, as one plain name. */
|
||||||
|
const agentKey = (rel) =>
|
||||||
|
rel
|
||||||
|
.replace(/\.jsonl$/, "")
|
||||||
|
.split(path.sep)
|
||||||
|
.join(".")
|
||||||
|
.replace(/[^\w.-]/g, "_")
|
||||||
|
.replace(/^\.+/, "")
|
||||||
|
.slice(0, 120);
|
||||||
|
|
||||||
|
/** The agents-table id of a subagent journal. */
|
||||||
|
const agentRowId = (sessionId, key) => `omp-${sessionId}-${key}`;
|
||||||
|
|
||||||
|
/** Every journal: `{path, sessionId, agent ('' = the session's own), mtime, size}`. */
|
||||||
|
function journalFiles() {
|
||||||
|
const members = require("./run-as")
|
||||||
|
.memberHomes()
|
||||||
|
.map((h) => path.join(h, ".omp"));
|
||||||
|
const out = [];
|
||||||
|
for (const dir of [getOmpDir(), ...members]) {
|
||||||
|
journalFilesIn(path.join(dir, "agent", "sessions"), out);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statFile(file) {
|
||||||
|
try {
|
||||||
|
const st = fs.lstatSync(file);
|
||||||
|
return st.isFile() ? st : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function journalFilesIn(root, out) {
|
||||||
|
let projects;
|
||||||
|
try {
|
||||||
|
projects = fs.readdirSync(root, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const p of projects) {
|
||||||
|
if (!p.isDirectory()) continue;
|
||||||
|
const dir = path.join(root, p.name);
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The session's own journal before its subagents': a subagent row needs
|
||||||
|
// its session row.
|
||||||
|
entries.sort((a, b) => Number(b.isFile()) - Number(a.isFile()));
|
||||||
|
for (const e of entries) {
|
||||||
|
if (out.length >= MAX_FILES) return;
|
||||||
|
const full = path.join(dir, e.name);
|
||||||
|
let m;
|
||||||
|
if (e.isFile() && (m = SESSION_FILE.exec(e.name))) {
|
||||||
|
const st = statFile(full);
|
||||||
|
if (st) {
|
||||||
|
out.push({
|
||||||
|
path: full,
|
||||||
|
sessionId: m[1],
|
||||||
|
agent: "",
|
||||||
|
mtime: Math.floor(st.mtimeMs),
|
||||||
|
size: st.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (e.isDirectory() && (m = SESSION_DIR.exec(e.name))) {
|
||||||
|
subagentFiles(full, full, m[1], 0, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subagentFiles(base, dir, sessionId, depth, out) {
|
||||||
|
if (depth >= MAX_DEPTH) return;
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const e of entries) {
|
||||||
|
if (out.length >= MAX_FILES) return;
|
||||||
|
const full = path.join(dir, e.name);
|
||||||
|
// Real folders and files only: a link is never followed.
|
||||||
|
if (e.isDirectory()) subagentFiles(base, full, sessionId, depth + 1, out);
|
||||||
|
else if (e.isFile() && e.name.endsWith(".jsonl")) {
|
||||||
|
const st = statFile(full);
|
||||||
|
const key = agentKey(path.relative(base, full));
|
||||||
|
if (st && key) {
|
||||||
|
out.push({
|
||||||
|
path: full,
|
||||||
|
sessionId,
|
||||||
|
agent: key,
|
||||||
|
mtime: Math.floor(st.mtimeMs),
|
||||||
|
size: st.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh per-journal state. */
|
||||||
|
const emptyState = () => ({
|
||||||
|
v: 1,
|
||||||
|
cwd: null,
|
||||||
|
startedAt: null,
|
||||||
|
lastAt: null,
|
||||||
|
model: null,
|
||||||
|
firstPrompt: null,
|
||||||
|
exited: false,
|
||||||
|
usage: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A message's text parts, joined. */
|
||||||
|
function textOf(content) {
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (!Array.isArray(content)) return "";
|
||||||
|
return content
|
||||||
|
.map((c) => (c && c.type === "text" && typeof c.text === "string" ? c.text : ""))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A model id without its provider (`anthropic/claude-sonnet-4-5` → `claude-sonnet-4-5`). */
|
||||||
|
const bareModel = (m) =>
|
||||||
|
typeof m === "string" && m ? m.slice(m.lastIndexOf("/") + 1).slice(0, 128) : null;
|
||||||
|
|
||||||
|
const num = (v) => (Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
||||||
|
|
||||||
|
/** Fold one journal line into `state`. */
|
||||||
|
function applyEntry(state, e) {
|
||||||
|
if (!e || typeof e !== "object") return;
|
||||||
|
const at = typeof e.timestamp === "string" ? e.timestamp : null;
|
||||||
|
switch (e.type) {
|
||||||
|
case "session":
|
||||||
|
if (typeof e.cwd === "string" && path.isAbsolute(e.cwd)) state.cwd = e.cwd;
|
||||||
|
if (at && !state.startedAt) state.startedAt = at;
|
||||||
|
break;
|
||||||
|
case "model_change":
|
||||||
|
state.model = bareModel(e.model) || state.model;
|
||||||
|
state.exited = false;
|
||||||
|
break;
|
||||||
|
case "message": {
|
||||||
|
const m = e.message || {};
|
||||||
|
state.exited = false;
|
||||||
|
if (at) state.lastAt = at;
|
||||||
|
if (m.role === "user" && !state.firstPrompt) {
|
||||||
|
const text = textOf(m.content).trim();
|
||||||
|
if (text && !text.startsWith("<system-reminder>")) state.firstPrompt = text.slice(0, 500);
|
||||||
|
}
|
||||||
|
if (m.role === "assistant" && m.usage && typeof m.usage === "object") {
|
||||||
|
const model = bareModel(m.model) || state.model || "unknown";
|
||||||
|
state.model = model;
|
||||||
|
const u = (state.usage[model] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
||||||
|
u.input += num(m.usage.input);
|
||||||
|
u.output += num(m.usage.output);
|
||||||
|
u.cacheRead += num(m.usage.cacheRead);
|
||||||
|
u.cacheWrite += num(m.usage.cacheWrite);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "custom":
|
||||||
|
if (e.customType === "session_exit") state.exited = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The journal's title slot (line 1), or null. */
|
||||||
|
async function readTitle(file) {
|
||||||
|
let fh;
|
||||||
|
try {
|
||||||
|
fh = await require("./run-as").openSafeAsync(file);
|
||||||
|
const buf = Buffer.alloc(HEAD_BYTES);
|
||||||
|
const { bytesRead } = await fh.read(buf, 0, HEAD_BYTES, 0);
|
||||||
|
const first = buf.toString("utf8", 0, bytesRead).split("\n")[0];
|
||||||
|
const o = JSON.parse(first);
|
||||||
|
return o?.type === "title" && typeof o.title === "string" && o.title.trim()
|
||||||
|
? o.title.trim().slice(0, 200)
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
await fh?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSeen = db.prepare("SELECT * FROM omp_sessions WHERE path = ?");
|
||||||
|
const upsertSeen = db.prepare(
|
||||||
|
`INSERT INTO omp_sessions (path, session_id, agent, size, mtime_ms, offset, state, title, updated_at)
|
||||||
|
VALUES (@path, @sessionId, @agent, @size, @mtime, @offset, @state, @title,
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
ON CONFLICT(path) DO UPDATE SET session_id = excluded.session_id, agent = excluded.agent,
|
||||||
|
size = excluded.size, mtime_ms = excluded.mtime_ms, offset = excluded.offset,
|
||||||
|
state = excluded.state, title = excluded.title, updated_at = excluded.updated_at`
|
||||||
|
);
|
||||||
|
const filesOf = db.prepare("SELECT * FROM omp_sessions WHERE session_id = ? ORDER BY agent");
|
||||||
|
const activeOmp = db.prepare("SELECT id FROM sessions WHERE tool = 'omp' AND status = 'active'");
|
||||||
|
const heldByPane = db.prepare(
|
||||||
|
"SELECT 1 FROM dashboard_runs WHERE session_id = ? AND status IN ('running', 'spawning') LIMIT 1"
|
||||||
|
);
|
||||||
|
const insertSession = db.prepare(
|
||||||
|
`INSERT INTO sessions (id, name, status, cwd, model, started_at, ended_at, updated_at, metadata, tool)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'omp')`
|
||||||
|
);
|
||||||
|
const updateSession = db.prepare(
|
||||||
|
`UPDATE sessions SET name = ?, status = ?, model = COALESCE(?, model), ended_at = ?,
|
||||||
|
updated_at = ?, cwd = COALESCE(cwd, ?), started_at = COALESCE(?, started_at)
|
||||||
|
WHERE id = ? AND tool = 'omp'`
|
||||||
|
);
|
||||||
|
const clearUsage = db.prepare("DELETE FROM token_usage WHERE session_id = ?");
|
||||||
|
const agentTimes = db.prepare(
|
||||||
|
"UPDATE agents SET started_at = COALESCE(?, started_at), ended_at = ? WHERE id = ?"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A write "in the future" (a clock that runs ahead) is not proof of activity.
|
||||||
|
const fresh = (mtime, now) => mtime - now < FUTURE_SLACK_MS && now - mtime < ACTIVE_MS;
|
||||||
|
|
||||||
|
const parseState = (row) => {
|
||||||
|
try {
|
||||||
|
return { ...emptyState(), ...JSON.parse(row.state) };
|
||||||
|
} catch {
|
||||||
|
return emptyState();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Read what one journal wrote since the last pass into its `omp_sessions` row. */
|
||||||
|
async function readJournal(f) {
|
||||||
|
const seen = getSeen.get(f.path);
|
||||||
|
let state = seen && seen.session_id === f.sessionId ? parseState(seen) : emptyState();
|
||||||
|
let offset = seen && seen.session_id === f.sessionId ? seen.offset : 0;
|
||||||
|
// Smaller than what was read: rewritten, so read again from the top.
|
||||||
|
if (f.size < offset) {
|
||||||
|
state = emptyState();
|
||||||
|
offset = 0;
|
||||||
|
}
|
||||||
|
const got = await readNew(f.path, offset, f.size);
|
||||||
|
for (const line of got.lines) {
|
||||||
|
if (!line) continue;
|
||||||
|
try {
|
||||||
|
applyEntry(state, JSON.parse(line));
|
||||||
|
} catch {
|
||||||
|
/* a line that is not JSON: skipped */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const title = f.agent ? null : await readTitle(f.path);
|
||||||
|
upsertSeen.run({
|
||||||
|
path: f.path,
|
||||||
|
sessionId: f.sessionId,
|
||||||
|
agent: f.agent,
|
||||||
|
size: f.size,
|
||||||
|
// A journal read only part-way is re-read next pass even if unchanged.
|
||||||
|
mtime: got.offset < f.size ? -1 : f.mtime,
|
||||||
|
offset: got.offset,
|
||||||
|
state: JSON.stringify(state),
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite one session's row, token usage and subagents from its journals'
|
||||||
|
* rows. @returns the session's broadcast type, or null (nothing written).
|
||||||
|
*/
|
||||||
|
function writeSession(sessionId, now, broadcast) {
|
||||||
|
const files = filesOf.all(sessionId);
|
||||||
|
const main = files.find((r) => r.agent === "");
|
||||||
|
if (!main) return null;
|
||||||
|
const existing = stmts.getSession.get(sessionId);
|
||||||
|
if (existing && existing.tool !== "omp") return null;
|
||||||
|
const ms = parseState(main);
|
||||||
|
const runAs = require("./run-as");
|
||||||
|
// S3: a member's journal names only their own folder and session.
|
||||||
|
if (!runAs.sameOwner(main.path, ms.cwd, existing?.cwd)) return null;
|
||||||
|
const lastMtime = Math.max(...files.map((r) => r.mtime_ms));
|
||||||
|
const lastWrite = new Date(lastMtime > 0 ? lastMtime : now).toISOString();
|
||||||
|
const active = !ms.exited && (fresh(lastMtime, now) || !!heldByPane.get(sessionId));
|
||||||
|
const status = active ? "active" : "completed";
|
||||||
|
const endedAt = active ? null : lastWrite;
|
||||||
|
const firstLine = ms.firstPrompt ? ms.firstPrompt.split("\n")[0].slice(0, 60) : null;
|
||||||
|
const name = main.title || firstLine || existing?.name || `OMP ${sessionId.slice(0, 8)}`;
|
||||||
|
const usage = {};
|
||||||
|
for (const r of files) {
|
||||||
|
for (const [m, u] of Object.entries(parseState(r).usage)) {
|
||||||
|
const t = (usage[m] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
||||||
|
t.input += u.input;
|
||||||
|
t.output += u.output;
|
||||||
|
t.cacheRead += u.cacheRead;
|
||||||
|
t.cacheWrite += u.cacheWrite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const agentEvents = [];
|
||||||
|
db.transaction(() => {
|
||||||
|
if (existing) {
|
||||||
|
updateSession.run(name, status, ms.model, endedAt, lastWrite, ms.cwd, ms.startedAt, sessionId);
|
||||||
|
} else {
|
||||||
|
insertSession.run(
|
||||||
|
sessionId,
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
ms.cwd,
|
||||||
|
ms.model,
|
||||||
|
ms.startedAt || lastWrite,
|
||||||
|
endedAt,
|
||||||
|
lastWrite,
|
||||||
|
JSON.stringify({ source: "omp-journal" })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
claimToolSessions(sessionId); // its workspace, before the broadcast
|
||||||
|
// Every usage row of an omp session is this module's: rewritten whole.
|
||||||
|
clearUsage.run(sessionId);
|
||||||
|
for (const [m, t] of Object.entries(usage)) {
|
||||||
|
stmts.setTokenUsage.run(
|
||||||
|
sessionId,
|
||||||
|
m,
|
||||||
|
"standard",
|
||||||
|
"global",
|
||||||
|
"standard",
|
||||||
|
t.input,
|
||||||
|
t.output,
|
||||||
|
t.cacheRead,
|
||||||
|
t.cacheWrite,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const r of files) {
|
||||||
|
if (!r.agent || !runAs.sameOwner(main.path, r.path)) continue;
|
||||||
|
const s = parseState(r);
|
||||||
|
const id = agentRowId(sessionId, r.agent);
|
||||||
|
const done = !active || s.exited || !fresh(r.mtime_ms, now);
|
||||||
|
const agentStatus = done ? "completed" : "working";
|
||||||
|
const ended = done ? new Date(r.mtime_ms > 0 ? r.mtime_ms : now).toISOString() : null;
|
||||||
|
const label = r.agent.split(".").pop() || r.agent;
|
||||||
|
const prior = stmts.getAgent.get(id);
|
||||||
|
if (prior && prior.session_id !== sessionId) continue;
|
||||||
|
if (!prior) {
|
||||||
|
stmts.insertAgent.run(
|
||||||
|
id,
|
||||||
|
sessionId,
|
||||||
|
label,
|
||||||
|
"subagent",
|
||||||
|
"task",
|
||||||
|
agentStatus,
|
||||||
|
s.firstPrompt,
|
||||||
|
null,
|
||||||
|
JSON.stringify({ source: "omp-journal", transcript: r.agent })
|
||||||
|
);
|
||||||
|
} else if (prior.status !== agentStatus || (s.firstPrompt && prior.task !== s.firstPrompt)) {
|
||||||
|
stmts.updateAgent.run(null, agentStatus, s.firstPrompt, null, ended, null, id);
|
||||||
|
} else continue;
|
||||||
|
agentTimes.run(s.startedAt, ended, id);
|
||||||
|
agentEvents.push([prior ? "agent_updated" : "agent_created", id]);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
for (const [type, id] of agentEvents) broadcast(type, stmts.getAgent.get(id));
|
||||||
|
return existing ? "session_updated" : "session_created";
|
||||||
|
}
|
||||||
|
|
||||||
|
let running = null;
|
||||||
|
// Fresh sessions the last pass left for a later one to settle (the poll keeps
|
||||||
|
// its base pace while there are any).
|
||||||
|
let lastPending = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One pass: every changed journal is read on from where it stopped, then each
|
||||||
|
* session it touched — and each one still `active` — is rewritten.
|
||||||
|
* @returns {Promise<{files: number, sessions: number}>}
|
||||||
|
*/
|
||||||
|
function ingestOmpUsage({ broadcast = () => {}, now = Date.now() } = {}) {
|
||||||
|
if (running) return running;
|
||||||
|
running = (async () => {
|
||||||
|
let read = 0;
|
||||||
|
let written = 0;
|
||||||
|
const touched = new Set();
|
||||||
|
for (const f of journalFiles()) {
|
||||||
|
const seen = getSeen.get(f.path);
|
||||||
|
if (seen && seen.mtime_ms === f.mtime && seen.size === f.size) continue;
|
||||||
|
try {
|
||||||
|
await readJournal(f);
|
||||||
|
read += 1;
|
||||||
|
touched.add(f.sessionId);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[omp-usage] skipped ${f.path}: ${err.message}`);
|
||||||
|
}
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
}
|
||||||
|
for (const { id } of activeOmp.all()) touched.add(id);
|
||||||
|
let pending = 0;
|
||||||
|
for (const id of touched) {
|
||||||
|
try {
|
||||||
|
const type = writeSession(id, now, broadcast);
|
||||||
|
if (!type) continue;
|
||||||
|
const s = stmts.getSession.get(id);
|
||||||
|
if (s?.status === "active") pending += 1;
|
||||||
|
written += 1;
|
||||||
|
broadcast(type, s);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[omp-usage] session ${id}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastPending = pending;
|
||||||
|
return { files: read, sessions: written };
|
||||||
|
})().finally(() => {
|
||||||
|
running = null;
|
||||||
|
});
|
||||||
|
return running;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The journal behind a session's transcript: its own (`agent` '' / "main"),
|
||||||
|
* or one subagent's by key. Null when unknown.
|
||||||
|
*/
|
||||||
|
function journalPath(sessionId, agent = "") {
|
||||||
|
const key = agent === "main" ? "" : agent || "";
|
||||||
|
const row = db
|
||||||
|
.prepare("SELECT path FROM omp_sessions WHERE session_id = ? AND agent = ?")
|
||||||
|
.get(sessionId, key);
|
||||||
|
return row?.path || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A session's subagent journals: `[{agent, name, task, startedAt}]`. */
|
||||||
|
function subagentJournals(sessionId) {
|
||||||
|
return filesOf
|
||||||
|
.all(sessionId)
|
||||||
|
.filter((r) => r.agent)
|
||||||
|
.map((r) => {
|
||||||
|
const s = parseState(r);
|
||||||
|
return {
|
||||||
|
agent: r.agent,
|
||||||
|
name: r.agent.split(".").pop() || r.agent,
|
||||||
|
task: s.firstPrompt,
|
||||||
|
startedAt: s.startedAt,
|
||||||
|
rowId: agentRowId(sessionId, r.agent),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First pass at once (in the background), then every DASHBOARD_OMP_POLL_MS
|
||||||
|
* (default 15 s; 0 disables) — slowed to once a minute after a pass that read
|
||||||
|
* nothing, wrote nothing and left no active session to settle.
|
||||||
|
*/
|
||||||
|
function startOmpUsagePoll(broadcast) {
|
||||||
|
const POLL_MS = process.env.DASHBOARD_OMP_POLL_MS
|
||||||
|
? Number(process.env.DASHBOARD_OMP_POLL_MS)
|
||||||
|
: 15_000;
|
||||||
|
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
|
||||||
|
require("./backoff-poll").startBackoffPoll(
|
||||||
|
() =>
|
||||||
|
ingestOmpUsage({ broadcast }).then((r) => {
|
||||||
|
if (r.sessions > 0 && pricingSync.syncEnabled()) void pricingSync.syncPricing(db);
|
||||||
|
return r.files + r.sessions + lastPending > 0;
|
||||||
|
}),
|
||||||
|
{ baseMs: POLL_MS }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getOmpDir,
|
||||||
|
agentKey,
|
||||||
|
applyEntry,
|
||||||
|
emptyState,
|
||||||
|
ingestOmpUsage,
|
||||||
|
journalPath,
|
||||||
|
subagentJournals,
|
||||||
|
startOmpUsagePoll,
|
||||||
|
};
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
/**
|
||||||
|
* @file OpenCode (`opencode`) sessions, subagents and token usage, read from
|
||||||
|
* OpenCode's own SQLite store (`~/.local/share/opencode/opencode.db`, under
|
||||||
|
* `XDG_DATA_HOME` when set). This is how an OpenCode session reaches
|
||||||
|
* `sessions` (as `tool = 'opencode'`), its tokens `token_usage` and its
|
||||||
|
* subagents `agents` — OpenCode has no hooks AgentHub installs.
|
||||||
|
*
|
||||||
|
* What the store holds (opencode 1.19, verified against a file it wrote — not
|
||||||
|
* a documented format):
|
||||||
|
* - `session(id, parent_id, directory, title, time_created, time_updated,
|
||||||
|
* time_archived)` — a session the `task` tool spawned has `parent_id` set,
|
||||||
|
* and is shown as a subagent of its root session;
|
||||||
|
* - `message(session_id, data)` — an assistant message's JSON carries
|
||||||
|
* `modelID` and `tokens: {input, output, reasoning, cache: {read, write}}`
|
||||||
|
* (input NOT including cache reads; reasoning is counted as output here);
|
||||||
|
* - `part(message_id, session_id, data)` — the message's text, reasoning and
|
||||||
|
* tool parts, read only for a transcript.
|
||||||
|
* The file is re-read when its mtime (or its non-empty `-wal`'s) changes; per
|
||||||
|
* root session the newest `time_updated` seen lives in `opencode_sessions`,
|
||||||
|
* so a session the user deleted stays deleted until OpenCode writes to it
|
||||||
|
* again. Status: active while its tree was updated in the last 2 minutes or a
|
||||||
|
* live dashboard pane holds it, else completed. S3: a member's store (and its
|
||||||
|
* WAL) is read through the guard into a private copy, never opened in place.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { db, stmts, Database } = require("../db");
|
||||||
|
const { claimToolSessions } = require("./dashboard-runs");
|
||||||
|
const pricingSync = require("./pricing-sync");
|
||||||
|
|
||||||
|
const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active
|
||||||
|
const FUTURE_SLACK_MS = 1000;
|
||||||
|
const SESSION_ID = /^[A-Za-z0-9_-]{8,120}$/;
|
||||||
|
const MAX_COPY = 256 << 20;
|
||||||
|
|
||||||
|
/** OpenCode's data dir: `AGENTHUB_OPENCODE_DIR`, `$XDG_DATA_HOME/opencode`, else `~/.local/share/opencode`. */
|
||||||
|
function getOpencodeDir() {
|
||||||
|
if (process.env.AGENTHUB_OPENCODE_DIR) return process.env.AGENTHUB_OPENCODE_DIR;
|
||||||
|
const xdg = process.env.XDG_DATA_HOME;
|
||||||
|
return path.join(xdg && path.isAbsolute(xdg) ? xdg : path.join(os.homedir(), ".local", "share"), "opencode");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every store: `{path, mtime}` — this account's, then every member's. */
|
||||||
|
function storeFiles() {
|
||||||
|
const members = require("./run-as")
|
||||||
|
.memberHomes()
|
||||||
|
.map((h) => path.join(h, ".local", "share", "opencode"));
|
||||||
|
const out = [];
|
||||||
|
for (const dir of [getOpencodeDir(), ...members]) {
|
||||||
|
const file = path.join(dir, "opencode.db");
|
||||||
|
try {
|
||||||
|
const st = fs.lstatSync(file);
|
||||||
|
if (!st.isFile()) continue;
|
||||||
|
let mtime = st.mtimeMs;
|
||||||
|
try {
|
||||||
|
const wal = fs.lstatSync(`${file}-wal`);
|
||||||
|
if (wal.isFile() && wal.size > 0) mtime = Math.max(mtime, wal.mtimeMs);
|
||||||
|
} catch {
|
||||||
|
/* no WAL right now */
|
||||||
|
}
|
||||||
|
out.push({ path: file, mtime: Math.floor(mtime) });
|
||||||
|
} catch {
|
||||||
|
/* no store */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn(conn)` on a store, read-only. A member's store (S3) and its WAL are
|
||||||
|
* copied through the guard first: SQLite opens by NAME, and a member's FIFO
|
||||||
|
* standing in for the `-wal` would block this thread.
|
||||||
|
*/
|
||||||
|
function withStore(file, fn) {
|
||||||
|
const { ownerForRead, readFileSafe } = require("./run-as");
|
||||||
|
let tmp = null;
|
||||||
|
try {
|
||||||
|
let open = file;
|
||||||
|
if (require("./linux-accounts").s3Enabled() && ownerForRead(file)) {
|
||||||
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-opencode-"));
|
||||||
|
open = path.join(tmp, "opencode.db");
|
||||||
|
fs.writeFileSync(open, readFileSafe(file, null, { max: MAX_COPY }));
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(`${open}-wal`, readFileSafe(`${file}-wal`, null, { max: MAX_COPY }));
|
||||||
|
} catch {
|
||||||
|
/* no WAL (or not theirs): what is in the main file */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const conn = new Database(open, { readonly: !tmp, fileMustExist: true });
|
||||||
|
try {
|
||||||
|
return fn(conn);
|
||||||
|
} finally {
|
||||||
|
conn.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A session's model column (`{"id": …}` JSON, or a plain id), or null. */
|
||||||
|
function modelOf(raw) {
|
||||||
|
if (typeof raw !== "string" || !raw) return null;
|
||||||
|
try {
|
||||||
|
const o = JSON.parse(raw);
|
||||||
|
return typeof o?.id === "string" ? o.id.slice(0, 128) : null;
|
||||||
|
} catch {
|
||||||
|
return raw.slice(0, 128);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything a pass needs from one store: `[{root, children, usage, updated}]`
|
||||||
|
* per root session — `usage` per model over the root and every descendant.
|
||||||
|
*/
|
||||||
|
function readStore(file) {
|
||||||
|
return withStore(file, (conn) => {
|
||||||
|
const rows = conn
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, parent_id, directory, title, time_created, time_updated, time_archived, model
|
||||||
|
FROM session`
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
const byId = new Map(rows.map((r) => [r.id, r]));
|
||||||
|
const rootOf = (r) => {
|
||||||
|
let cur = r;
|
||||||
|
for (let i = 0; i < 32 && cur.parent_id && byId.has(cur.parent_id); i++) {
|
||||||
|
cur = byId.get(cur.parent_id);
|
||||||
|
}
|
||||||
|
return cur;
|
||||||
|
};
|
||||||
|
const trees = new Map();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!SESSION_ID.test(r.id)) continue;
|
||||||
|
const root = rootOf(r);
|
||||||
|
if (!SESSION_ID.test(root.id)) continue;
|
||||||
|
const t = trees.get(root.id) || { root, children: [], usage: {}, updated: 0, model: null };
|
||||||
|
if (r !== root) t.children.push(r);
|
||||||
|
t.updated = Math.max(t.updated, Number(r.time_updated) || 0);
|
||||||
|
trees.set(root.id, t);
|
||||||
|
}
|
||||||
|
const sums = conn
|
||||||
|
.prepare(
|
||||||
|
`SELECT session_id,
|
||||||
|
json_extract(data, '$.modelID') AS model,
|
||||||
|
SUM(COALESCE(json_extract(data, '$.tokens.input'), 0)) AS input,
|
||||||
|
SUM(COALESCE(json_extract(data, '$.tokens.output'), 0)
|
||||||
|
+ COALESCE(json_extract(data, '$.tokens.reasoning'), 0)) AS output,
|
||||||
|
SUM(COALESCE(json_extract(data, '$.tokens.cache.read'), 0)) AS cache_read,
|
||||||
|
SUM(COALESCE(json_extract(data, '$.tokens.cache.write'), 0)) AS cache_write,
|
||||||
|
MAX(time_created) AS last
|
||||||
|
FROM message
|
||||||
|
WHERE json_extract(data, '$.role') = 'assistant'
|
||||||
|
GROUP BY session_id, model`
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
for (const s of sums) {
|
||||||
|
const r = byId.get(s.session_id);
|
||||||
|
const t = r && trees.get(rootOf(r).id);
|
||||||
|
if (!t) continue;
|
||||||
|
const model = typeof s.model === "string" && s.model ? s.model.slice(0, 128) : "unknown";
|
||||||
|
const u = (t.usage[model] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
||||||
|
u.input += Number(s.input) || 0;
|
||||||
|
u.output += Number(s.output) || 0;
|
||||||
|
u.cacheRead += Number(s.cache_read) || 0;
|
||||||
|
u.cacheWrite += Number(s.cache_write) || 0;
|
||||||
|
if (r === t.root && (!t.lastAt || s.last > t.lastAt)) {
|
||||||
|
t.lastAt = s.last;
|
||||||
|
t.model = model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...trees.values()];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStore = db.prepare("SELECT * FROM opencode_stores WHERE path = ?");
|
||||||
|
const upsertStore = db.prepare(
|
||||||
|
`INSERT INTO opencode_stores (path, mtime_ms, updated_at)
|
||||||
|
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
ON CONFLICT(path) DO UPDATE SET mtime_ms = excluded.mtime_ms, updated_at = excluded.updated_at`
|
||||||
|
);
|
||||||
|
const getTracked = db.prepare("SELECT * FROM opencode_sessions WHERE session_id = ?");
|
||||||
|
const upsertTracked = db.prepare(
|
||||||
|
`INSERT INTO opencode_sessions (session_id, store, time_updated, updated_at)
|
||||||
|
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
ON CONFLICT(session_id) DO UPDATE SET store = excluded.store,
|
||||||
|
time_updated = excluded.time_updated, updated_at = excluded.updated_at`
|
||||||
|
);
|
||||||
|
const activeOpencode = db.prepare(
|
||||||
|
"SELECT s.id, t.time_updated FROM sessions s JOIN opencode_sessions t ON t.session_id = s.id WHERE s.tool = 'opencode' AND s.status = 'active'"
|
||||||
|
);
|
||||||
|
const heldByPane = db.prepare(
|
||||||
|
"SELECT 1 FROM dashboard_runs WHERE session_id = ? AND status IN ('running', 'spawning') LIMIT 1"
|
||||||
|
);
|
||||||
|
const insertSession = db.prepare(
|
||||||
|
`INSERT INTO sessions (id, name, status, cwd, model, started_at, ended_at, updated_at, metadata, tool)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'opencode')`
|
||||||
|
);
|
||||||
|
const updateSession = db.prepare(
|
||||||
|
`UPDATE sessions SET name = ?, status = ?, model = COALESCE(?, model), ended_at = ?,
|
||||||
|
updated_at = ?, cwd = COALESCE(cwd, ?), started_at = COALESCE(?, started_at)
|
||||||
|
WHERE id = ? AND tool = 'opencode'`
|
||||||
|
);
|
||||||
|
const settle = db.prepare(
|
||||||
|
"UPDATE sessions SET status = 'completed', ended_at = ? WHERE id = ? AND tool = 'opencode'"
|
||||||
|
);
|
||||||
|
const clearUsage = db.prepare("DELETE FROM token_usage WHERE session_id = ?");
|
||||||
|
const agentTimes = db.prepare(
|
||||||
|
"UPDATE agents SET started_at = COALESCE(?, started_at), ended_at = ? WHERE id = ?"
|
||||||
|
);
|
||||||
|
|
||||||
|
const fresh = (t, now) => t - now < FUTURE_SLACK_MS && now - t < ACTIVE_MS;
|
||||||
|
const iso = (t) => (Number.isFinite(Number(t)) && Number(t) > 0 ? new Date(Number(t)).toISOString() : null);
|
||||||
|
|
||||||
|
/** Write one root session's row, usage and subagents. @returns its broadcast type, or null. */
|
||||||
|
function writeSession(store, t, now, broadcast) {
|
||||||
|
const r = t.root;
|
||||||
|
const existing = stmts.getSession.get(r.id);
|
||||||
|
if (existing && existing.tool !== "opencode") return null;
|
||||||
|
const cwd = typeof r.directory === "string" && path.isAbsolute(r.directory) ? r.directory : null;
|
||||||
|
const runAs = require("./run-as");
|
||||||
|
// S3: a member's store names only their own folder and session.
|
||||||
|
if (!runAs.sameOwner(store, cwd, existing?.cwd)) return null;
|
||||||
|
const lastWrite = iso(t.updated) || new Date(now).toISOString();
|
||||||
|
const active = !r.time_archived && (fresh(t.updated, now) || !!heldByPane.get(r.id));
|
||||||
|
const status = active ? "active" : "completed";
|
||||||
|
const endedAt = active ? null : lastWrite;
|
||||||
|
const name =
|
||||||
|
(typeof r.title === "string" && r.title.trim().slice(0, 200)) ||
|
||||||
|
existing?.name ||
|
||||||
|
`OpenCode ${r.id.slice(4, 12)}`;
|
||||||
|
const model = t.model || modelOf(r.model);
|
||||||
|
const agentEvents = [];
|
||||||
|
db.transaction(() => {
|
||||||
|
if (existing) {
|
||||||
|
updateSession.run(name, status, model, endedAt, lastWrite, cwd, iso(r.time_created), r.id);
|
||||||
|
} else {
|
||||||
|
insertSession.run(
|
||||||
|
r.id,
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
cwd,
|
||||||
|
model,
|
||||||
|
iso(r.time_created) || lastWrite,
|
||||||
|
endedAt,
|
||||||
|
lastWrite,
|
||||||
|
JSON.stringify({ source: "opencode-store" })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
claimToolSessions(r.id);
|
||||||
|
clearUsage.run(r.id);
|
||||||
|
for (const [m, u] of Object.entries(t.usage)) {
|
||||||
|
stmts.setTokenUsage.run(
|
||||||
|
r.id,
|
||||||
|
m,
|
||||||
|
"standard",
|
||||||
|
"global",
|
||||||
|
"standard",
|
||||||
|
u.input,
|
||||||
|
u.output,
|
||||||
|
u.cacheRead,
|
||||||
|
u.cacheWrite,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const c of t.children) {
|
||||||
|
const id = `opencode-${c.id}`;
|
||||||
|
const done = !active || !fresh(Number(c.time_updated) || 0, now);
|
||||||
|
const agentStatus = done ? "completed" : "working";
|
||||||
|
const ended = done ? iso(c.time_updated) : null;
|
||||||
|
const label = (typeof c.title === "string" && c.title.trim().slice(0, 120)) || c.id;
|
||||||
|
const prior = stmts.getAgent.get(id);
|
||||||
|
if (prior && prior.session_id !== r.id) continue;
|
||||||
|
if (!prior) {
|
||||||
|
stmts.insertAgent.run(
|
||||||
|
id,
|
||||||
|
r.id,
|
||||||
|
label,
|
||||||
|
"subagent",
|
||||||
|
"task",
|
||||||
|
agentStatus,
|
||||||
|
label,
|
||||||
|
null,
|
||||||
|
JSON.stringify({ source: "opencode-store", transcript: c.id })
|
||||||
|
);
|
||||||
|
} else if (prior.status !== agentStatus || prior.name !== label) {
|
||||||
|
stmts.updateAgent.run(label, agentStatus, label, null, ended, null, id);
|
||||||
|
} else continue;
|
||||||
|
agentTimes.run(iso(c.time_created), ended, id);
|
||||||
|
agentEvents.push([prior ? "agent_updated" : "agent_created", id]);
|
||||||
|
}
|
||||||
|
upsertTracked.run(r.id, store, t.updated);
|
||||||
|
})();
|
||||||
|
for (const [type, id] of agentEvents) broadcast(type, stmts.getAgent.get(id));
|
||||||
|
return existing ? "session_updated" : "session_created";
|
||||||
|
}
|
||||||
|
|
||||||
|
let running = null;
|
||||||
|
let lastPending = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One pass: every store whose file changed is read, and each root session
|
||||||
|
* updated since it was last written is rewritten; an `active` one that went
|
||||||
|
* quiet is settled.
|
||||||
|
* @returns {Promise<{files: number, sessions: number}>}
|
||||||
|
*/
|
||||||
|
function ingestOpencodeUsage({ broadcast = () => {}, now = Date.now() } = {}) {
|
||||||
|
if (running) return running;
|
||||||
|
running = (async () => {
|
||||||
|
let read = 0;
|
||||||
|
let written = 0;
|
||||||
|
const done = new Set();
|
||||||
|
for (const f of storeFiles()) {
|
||||||
|
const seen = getStore.get(f.path);
|
||||||
|
if (seen && seen.mtime_ms === f.mtime) continue;
|
||||||
|
let trees;
|
||||||
|
try {
|
||||||
|
trees = readStore(f.path);
|
||||||
|
} catch (err) {
|
||||||
|
if (getStore.get(f.path)?.mtime_ms !== f.mtime) {
|
||||||
|
console.warn(`[opencode-usage] skipped ${f.path}: ${err.message}`);
|
||||||
|
}
|
||||||
|
upsertStore.run(f.path, f.mtime);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
read += 1;
|
||||||
|
for (const t of trees) {
|
||||||
|
const tracked = getTracked.get(t.root.id);
|
||||||
|
const changed = !tracked || tracked.time_updated !== t.updated;
|
||||||
|
const s = stmts.getSession.get(t.root.id);
|
||||||
|
// Unchanged and deleted by the user: stays deleted.
|
||||||
|
if (!changed && (!s || s.status !== "active")) continue;
|
||||||
|
try {
|
||||||
|
const type = writeSession(f.path, t, now, broadcast);
|
||||||
|
if (!type) continue;
|
||||||
|
done.add(t.root.id);
|
||||||
|
written += 1;
|
||||||
|
broadcast(type, stmts.getSession.get(t.root.id));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[opencode-usage] session ${t.root.id}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
upsertStore.run(f.path, f.mtime);
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
}
|
||||||
|
// An active session in a store that did not change: settled once quiet.
|
||||||
|
let pending = 0;
|
||||||
|
for (const { id, time_updated: updated } of activeOpencode.all()) {
|
||||||
|
if (done.has(id)) {
|
||||||
|
pending += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fresh(updated, now) || heldByPane.get(id)) {
|
||||||
|
pending += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
settle.run(iso(updated) || new Date(now).toISOString(), id);
|
||||||
|
for (const a of stmts.listAgentsBySession.all(id)) {
|
||||||
|
if (a.status === "completed" || a.status === "error") continue;
|
||||||
|
stmts.updateAgent.run(null, "completed", null, null, iso(updated), null, a.id);
|
||||||
|
broadcast("agent_updated", stmts.getAgent.get(a.id));
|
||||||
|
}
|
||||||
|
written += 1;
|
||||||
|
broadcast("session_updated", stmts.getSession.get(id));
|
||||||
|
}
|
||||||
|
lastPending = pending;
|
||||||
|
return { files: read, sessions: written };
|
||||||
|
})().finally(() => {
|
||||||
|
running = null;
|
||||||
|
});
|
||||||
|
return running;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tool part's input/output, kept small. */
|
||||||
|
const short = (v, max = 10240) => {
|
||||||
|
const s = typeof v === "string" ? v : v === undefined ? "" : JSON.stringify(v);
|
||||||
|
return s.length > max ? `${s.slice(0, max)}[truncated]` : s;
|
||||||
|
};
|
||||||
|
const TEXT_MAX = 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One OpenCode session's conversation (`sessionId`, or one of its subagent
|
||||||
|
* sessions by `childId`), in the Claude transcript shape the client renders:
|
||||||
|
* `[{type, sender, timestamp, content}]`, oldest first. Null when unknown.
|
||||||
|
*/
|
||||||
|
function transcriptMessages(sessionId, childId = null) {
|
||||||
|
const tracked = getTracked.get(sessionId);
|
||||||
|
if (!tracked) return null;
|
||||||
|
const target = childId || sessionId;
|
||||||
|
if (!SESSION_ID.test(target)) return null;
|
||||||
|
return withStore(tracked.store, (conn) => {
|
||||||
|
if (childId) {
|
||||||
|
// Only a session of this tree.
|
||||||
|
let cur = conn.prepare("SELECT id, parent_id FROM session WHERE id = ?").get(childId);
|
||||||
|
for (let i = 0; i < 32 && cur && cur.id !== sessionId; i++) {
|
||||||
|
cur = cur.parent_id
|
||||||
|
? conn.prepare("SELECT id, parent_id FROM session WHERE id = ?").get(cur.parent_id)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (!cur) return null;
|
||||||
|
}
|
||||||
|
const messages = conn
|
||||||
|
.prepare("SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created, id")
|
||||||
|
.all(target);
|
||||||
|
const parts = conn
|
||||||
|
.prepare("SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created, id")
|
||||||
|
.all(target);
|
||||||
|
const byMessage = new Map();
|
||||||
|
for (const p of parts) {
|
||||||
|
let d;
|
||||||
|
try {
|
||||||
|
d = JSON.parse(p.data);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
(byMessage.get(p.message_id) || byMessage.set(p.message_id, []).get(p.message_id)).push(d);
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const m of messages) {
|
||||||
|
let info;
|
||||||
|
try {
|
||||||
|
info = JSON.parse(m.data);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const at = iso(m.time_created);
|
||||||
|
const role = info.role === "user" ? "user" : info.role === "assistant" ? "assistant" : null;
|
||||||
|
if (!role) continue;
|
||||||
|
const content = [];
|
||||||
|
const results = [];
|
||||||
|
for (const p of byMessage.get(m.id) || []) {
|
||||||
|
if (p.type === "text" && typeof p.text === "string" && p.text.trim() && !p.synthetic) {
|
||||||
|
content.push({ type: "text", text: short(p.text, TEXT_MAX) });
|
||||||
|
} else if (p.type === "reasoning" && typeof p.text === "string" && p.text.trim()) {
|
||||||
|
content.push({ type: "thinking", text: short(p.text) });
|
||||||
|
} else if (p.type === "tool") {
|
||||||
|
const st = p.state || {};
|
||||||
|
content.push({
|
||||||
|
type: "tool_use",
|
||||||
|
name: p.tool || "unknown",
|
||||||
|
id: p.callID || null,
|
||||||
|
input: st.input && typeof st.input === "object" ? st.input : {},
|
||||||
|
});
|
||||||
|
if (st.status === "completed" || st.status === "error") {
|
||||||
|
results.push({
|
||||||
|
type: "tool_result",
|
||||||
|
id: p.callID || null,
|
||||||
|
output: short(st.status === "error" ? st.error : st.output),
|
||||||
|
is_error: st.status === "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (content.length) {
|
||||||
|
const sender = role === "user" ? (childId && !out.length ? "orchestrator" : "user") : "assistant";
|
||||||
|
const msg = { type: role, sender, timestamp: at, content };
|
||||||
|
if (role === "assistant" && info.modelID) msg.model = info.modelID;
|
||||||
|
out.push(msg);
|
||||||
|
}
|
||||||
|
if (results.length) out.push({ type: "user", sender: "tool", timestamp: at, content: results });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A root session's subagent sessions: `[{id, name, rowId}]`. */
|
||||||
|
function subagentSessions(sessionId) {
|
||||||
|
return stmts
|
||||||
|
.listAgentsBySession.all(sessionId)
|
||||||
|
.filter((a) => a.id.startsWith("opencode-"))
|
||||||
|
.map((a) => ({ id: a.id.slice("opencode-".length), name: a.name, rowId: a.id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First pass at once, then every DASHBOARD_OPENCODE_POLL_MS (default 15 s; 0
|
||||||
|
* disables) — once a minute after a pass that found nothing to do.
|
||||||
|
*/
|
||||||
|
function startOpencodeUsagePoll(broadcast) {
|
||||||
|
const POLL_MS = process.env.DASHBOARD_OPENCODE_POLL_MS
|
||||||
|
? Number(process.env.DASHBOARD_OPENCODE_POLL_MS)
|
||||||
|
: 15_000;
|
||||||
|
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
|
||||||
|
require("./backoff-poll").startBackoffPoll(
|
||||||
|
() =>
|
||||||
|
ingestOpencodeUsage({ broadcast }).then((r) => {
|
||||||
|
if (r.sessions > 0 && pricingSync.syncEnabled()) void pricingSync.syncPricing(db);
|
||||||
|
return r.files + r.sessions + lastPending > 0;
|
||||||
|
}),
|
||||||
|
{ baseMs: POLL_MS }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getOpencodeDir,
|
||||||
|
ingestOpencodeUsage,
|
||||||
|
transcriptMessages,
|
||||||
|
subagentSessions,
|
||||||
|
startOpencodeUsagePoll,
|
||||||
|
};
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
* the driver's size and told so (`{type:"size", cols, rows}`) so its xterm
|
* the driver's size and told so (`{type:"size", cols, rows}`) so its xterm
|
||||||
* renders the same grid, scaled to fit, instead of each browser resizing tmux
|
* renders the same grid, scaled to fit, instead of each browser resizing tmux
|
||||||
* under the others. A driver that leaves sends `{type:"lead"}` to the rest.
|
* under the others. A driver that leaves sends `{type:"lead"}` to the rest.
|
||||||
|
* A keystroke that follows a mouse report first takes the pane out of tmux's
|
||||||
|
* copy-mode (the wheel's scrollback), so typing never lands in it.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -73,7 +75,16 @@ function attachBinary(platform = process.platform) {
|
|||||||
// the paste in that issue's measurements.
|
// the paste in that issue's measurements.
|
||||||
const PASTE_HOLD_MS = 700;
|
const PASTE_HOLD_MS = 700;
|
||||||
|
|
||||||
// runId -> { driver, viewers: Set<{ ws, pty, cols, rows }> }
|
// Input that is not typing: a mouse report (SGR `ESC[<b;x;yM`, X10 `ESC[M` +
|
||||||
|
// three bytes) or a focus in/out. A wheel report is what puts a pane into
|
||||||
|
// tmux's copy-mode, where every typed key is taken as a scrollback command —
|
||||||
|
// the pane looked frozen. So once a mouse report has passed, the next real
|
||||||
|
// keystroke first leaves copy-mode (`tmux.leaveCopyMode`), then is written.
|
||||||
|
const NOT_TYPING_RE = /\x1b\[<\d+;\d+;\d+[Mm]|\x1b\[M[\s\S]{3}|\x1b\[[IO]/g;
|
||||||
|
|
||||||
|
// runId -> { driver, viewers: Set<{ ws, pty, cols, rows }>, scrolled }
|
||||||
|
// (`scrolled`: a mouse report reached the pane since the last keystroke — per
|
||||||
|
// pane, since one browser may scroll and another type)
|
||||||
const groups = new Map();
|
const groups = new Map();
|
||||||
|
|
||||||
const tell = (v, msg) => {
|
const tell = (v, msg) => {
|
||||||
@@ -211,6 +222,16 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
|
|||||||
/* the attach exited while its input was held — nothing to write to */
|
/* the attach exited while its input was held — nothing to write to */
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Keystrokes after a scroll leave copy-mode first; see NOT_TYPING_RE.
|
||||||
|
function input(text) {
|
||||||
|
if (text.replace(NOT_TYPING_RE, "") === "") {
|
||||||
|
if (text.includes("\x1b[M") || text.includes("\x1b[<")) group.scrolled = true;
|
||||||
|
} else if (group.scrolled) {
|
||||||
|
group.scrolled = false;
|
||||||
|
tmux.leaveCopyMode(runId);
|
||||||
|
}
|
||||||
|
write(text);
|
||||||
|
}
|
||||||
function write(text) {
|
function write(text) {
|
||||||
if (me.exited) return; // the attach is gone: nothing reads this pipe
|
if (me.exited) return; // the attach is gone: nothing reads this pipe
|
||||||
const wait = holdUntil - Date.now();
|
const wait = holdUntil - Date.now();
|
||||||
@@ -248,7 +269,7 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
|
|||||||
const binary = typeof isBinary === "boolean" ? isBinary : !!(isBinary && isBinary.binary);
|
const binary = typeof isBinary === "boolean" ? isBinary : !!(isBinary && isBinary.binary);
|
||||||
const text = data.toString("utf8");
|
const text = data.toString("utf8");
|
||||||
if (binary) {
|
if (binary) {
|
||||||
write(text);
|
input(text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The browser's WebSocket API sends a JS string as a text frame, and
|
// The browser's WebSocket API sends a JS string as a text frame, and
|
||||||
@@ -262,7 +283,7 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
|
|||||||
try {
|
try {
|
||||||
msg = JSON.parse(text);
|
msg = JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
write(text);
|
input(text);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
|
if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
|
||||||
@@ -274,7 +295,7 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
write(text);
|
input(text);
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
|
|||||||
+15
-7
@@ -295,16 +295,18 @@ function buildCodexArgv({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Any catalog tool without a hand-written launcher: `[bin, ...args, ...resume?,
|
* Any catalog tool without a hand-written launcher: `[bin, ...args, ...resume?,
|
||||||
* ...bypass?, ...prompt]` (`[flag, model]` right after the fixed args when the
|
* ...bypass?, ...prompt]` (`...resumeById, <id>` in place of `resume` for a
|
||||||
|
* named session; `[flag, model]` right after the fixed args when the
|
||||||
* catalog declares a model flag). Every piece but the prompt and the
|
* catalog declares a model flag). Every piece but the prompt and the
|
||||||
* validated model comes from the catalog
|
* validated model comes from the catalog
|
||||||
* file, and the prompt is one argv element — or, for `after-start`, nothing
|
* file, and the prompt is one argv element — or, for `after-start`, nothing
|
||||||
* here: spawnRun pastes it into the pane once the CLI is up.
|
* here: spawnRun pastes it into the pane once the CLI is up.
|
||||||
*/
|
*/
|
||||||
function buildCatalogArgv(entry, bin, { model, resumeLast, bypass, initialPrompt }) {
|
function buildCatalogArgv(entry, bin, { model, resumeSessionId, resumeLast, bypass, initialPrompt }) {
|
||||||
const argv = [bin, ...entry.args];
|
const argv = [bin, ...entry.args];
|
||||||
if (model && entry.model) argv.push(entry.model.flag, model);
|
if (model && entry.model) argv.push(entry.model.flag, model);
|
||||||
if (resumeLast && entry.resume) argv.push(...entry.resume);
|
if (resumeSessionId && entry.resumeById) argv.push(...entry.resumeById, resumeSessionId);
|
||||||
|
else if (resumeLast && entry.resume) argv.push(...entry.resume);
|
||||||
if (bypass && entry.bypass) argv.push(...entry.bypass);
|
if (bypass && entry.bypass) argv.push(...entry.bypass);
|
||||||
const p = entry.prompt;
|
const p = entry.prompt;
|
||||||
if (initialPrompt && p.mode === "flag") argv.push(p.flag, promptArg(initialPrompt));
|
if (initialPrompt && p.mode === "flag") argv.push(p.flag, promptArg(initialPrompt));
|
||||||
@@ -437,15 +439,21 @@ function spawnRun(args) {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
resumeSessionId != null &&
|
resumeSessionId != null &&
|
||||||
(typeof resumeSessionId !== "string" || !/^[A-Za-z0-9-]{8,}$/.test(resumeSessionId))
|
(typeof resumeSessionId !== "string" || !/^[A-Za-z0-9_-]{8,}$/.test(resumeSessionId))
|
||||||
) {
|
) {
|
||||||
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
|
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
|
||||||
}
|
}
|
||||||
// Only the hand-written launchers can resume a NAMED session; a catalog
|
// Only the hand-written launchers and a catalog tool that declares
|
||||||
// tool would silently start fresh under a "resume".
|
// `resumeById` can resume a NAMED session; any other would silently start
|
||||||
if (resumeSessionId && !entry.launcher) {
|
// fresh under a "resume".
|
||||||
|
if (resumeSessionId && !entry.launcher && !entry.resumeById) {
|
||||||
throw makeErr("EUNSUPPORTED", `${tool} cannot resume a session by id`);
|
throw makeErr("EUNSUPPORTED", `${tool} cannot resume a session by id`);
|
||||||
}
|
}
|
||||||
|
// A catalog tool takes the id as an argv element of its own: never one that
|
||||||
|
// could read as a flag.
|
||||||
|
if (resumeSessionId && !entry.launcher && !/^[A-Za-z0-9]/.test(resumeSessionId)) {
|
||||||
|
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
|
||||||
|
}
|
||||||
if (worktree === true && entry.launcher !== "claude") {
|
if (worktree === true && entry.launcher !== "claude") {
|
||||||
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
|
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -608,6 +608,119 @@ function enableMouse(name) {
|
|||||||
} catch {
|
} catch {
|
||||||
/* tmux < 3.7 */
|
/* tmux < 3.7 */
|
||||||
}
|
}
|
||||||
|
// The wheel's copy-mode paints a yellow `[0/0]` position box in the pane's
|
||||||
|
// corner; an empty format draws none. A window option of a recent tmux
|
||||||
|
// only — an older one refuses it and keeps the box.
|
||||||
|
try {
|
||||||
|
run(["set-option", "-w", "-t", pane(name), "copy-mode-position-format", ""]);
|
||||||
|
} catch {
|
||||||
|
/* tmux without copy-mode-position-format */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (exact()) onServerOf(name, copyModePromptBindings());
|
||||||
|
} catch {
|
||||||
|
/* an old tmux without `if-shell -F`/`#{m:}`: the prompts stay */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (exact()) onServerOf(name, wheelBindings());
|
||||||
|
} catch {
|
||||||
|
/* an old tmux: the wheel keeps tmux's default speed */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tmux's wheel scrolls 5 lines a notch, and its first notch only ENTERS
|
||||||
|
// copy-mode without scrolling. In OUR panes a notch is WHEEL_LINES lines and
|
||||||
|
// the first one already scrolls; every other session keeps tmux's default
|
||||||
|
// binding, restored verbatim (same server-wide re-bind as the prompts below).
|
||||||
|
const WHEEL_LINES = 10;
|
||||||
|
const WHEEL_ROOT_GUARD = "#{||:#{alternate_on},#{pane_in_mode},#{mouse_any_flag}}";
|
||||||
|
|
||||||
|
/** One `;`-chained argv re-binding the wheel in `root` and both copy-mode tables. */
|
||||||
|
function wheelBindings() {
|
||||||
|
const scroll = (dir, n) => `select-pane ; send-keys -X -N ${n} scroll-${dir}`;
|
||||||
|
const cmds = [
|
||||||
|
[
|
||||||
|
...["bind-key", "-T", "root", "WheelUpPane", "if-shell", "-F", WHEEL_ROOT_GUARD],
|
||||||
|
"send-keys -M",
|
||||||
|
`if-shell -F "${OURS}" { copy-mode -e ; send-keys -X -N ${WHEEL_LINES} scroll-up } { copy-mode -e }`,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
for (const table of ["copy-mode", "copy-mode-vi"])
|
||||||
|
for (const [key, dir] of [
|
||||||
|
["WheelUpPane", "up"],
|
||||||
|
["WheelDownPane", "down"],
|
||||||
|
])
|
||||||
|
cmds.push([
|
||||||
|
...["bind-key", "-T", table, key, "if-shell", "-F", OURS],
|
||||||
|
scroll(dir, WHEEL_LINES),
|
||||||
|
scroll(dir, 5),
|
||||||
|
]);
|
||||||
|
return cmds.flatMap((c, i) => (i ? [";", ...c] : c));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wheel puts a pane into copy-mode, and there a typed `g` (`:` with vi
|
||||||
|
// keys), `f`, `/`, a digit… opens a tmux prompt — "(goto line)", "(jump
|
||||||
|
// forward)", "(repeat)" — instead of reaching the CLI. In OUR panes those keys
|
||||||
|
// leave copy-mode and are typed into the program; every other session on the
|
||||||
|
// server (the user's own tmux shares the default socket) keeps tmux's default
|
||||||
|
// binding, restored verbatim below. Key tables are server-wide, so this is
|
||||||
|
// re-bound on every attach like the options above (idempotent).
|
||||||
|
const OURS = "#{m:agenthub-lane-*,#{session_name}}";
|
||||||
|
const COPY_MODE_PROMPTS = {
|
||||||
|
"copy-mode": {
|
||||||
|
g: 'command-prompt -P -p "(goto line)" { send-keys -X goto-line -- "%%" }',
|
||||||
|
f: 'command-prompt -1P -p "(jump forward)" { send-keys -X jump-forward -- "%%" }',
|
||||||
|
F: 'command-prompt -1P -p "(jump backward)" { send-keys -X jump-backward -- "%%" }',
|
||||||
|
t: 'command-prompt -1P -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }',
|
||||||
|
T: 'command-prompt -1P -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }',
|
||||||
|
},
|
||||||
|
"copy-mode-vi": {
|
||||||
|
":": 'command-prompt -P -p "(goto line)" { send-keys -X goto-line -- "%%" }',
|
||||||
|
"/": 'command-prompt -P -T search -p "(search down)" { send-keys -X search-forward -- "%%" }',
|
||||||
|
"?": 'command-prompt -P -T search -p "(search up)" { send-keys -X search-backward -- "%%" }',
|
||||||
|
f: 'command-prompt -1P -p "(jump forward)" { send-keys -X jump-forward -- "%%" }',
|
||||||
|
F: 'command-prompt -1P -p "(jump backward)" { send-keys -X jump-backward -- "%%" }',
|
||||||
|
t: 'command-prompt -1P -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }',
|
||||||
|
T: 'command-prompt -1P -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }',
|
||||||
|
...Object.fromEntries(
|
||||||
|
[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => [
|
||||||
|
String(n),
|
||||||
|
`command-prompt -NP -I ${n} -p (repeat) { send-keys -N "%%" }`,
|
||||||
|
])
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One `;`-chained argv re-binding every key in `COPY_MODE_PROMPTS`. */
|
||||||
|
function copyModePromptBindings() {
|
||||||
|
const cmds = [];
|
||||||
|
for (const [table, keys] of Object.entries(COPY_MODE_PROMPTS))
|
||||||
|
for (const [key, original] of Object.entries(keys))
|
||||||
|
cmds.push([
|
||||||
|
"bind-key",
|
||||||
|
"-T",
|
||||||
|
table,
|
||||||
|
key,
|
||||||
|
"if-shell",
|
||||||
|
"-F",
|
||||||
|
OURS,
|
||||||
|
`send-keys -X cancel ; send-keys -l -- '${key}'`,
|
||||||
|
original,
|
||||||
|
]);
|
||||||
|
return cmds.flatMap((c, i) => (i ? [";", ...c] : c));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take the pane out of copy-mode (or any mode) so what is typed next reaches
|
||||||
|
* the program, not tmux's scrollback keys. A no-op for a pane in no mode;
|
||||||
|
* best-effort — a tmux without `copy-mode -q` (< 3.2) leaves it as it is.
|
||||||
|
*/
|
||||||
|
function leaveCopyMode(name) {
|
||||||
|
try {
|
||||||
|
onServerOf(name, ["copy-mode", "-q", "-t", pane(name)]);
|
||||||
|
} catch {
|
||||||
|
/* session gone or an old tmux */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -898,6 +1011,7 @@ module.exports = {
|
|||||||
hasSession,
|
hasSession,
|
||||||
newSession,
|
newSession,
|
||||||
enableMouse,
|
enableMouse,
|
||||||
|
leaveCopyMode,
|
||||||
paneCommand,
|
paneCommand,
|
||||||
sendCommand,
|
sendCommand,
|
||||||
sendText,
|
sendText,
|
||||||
|
|||||||
+43
-2
@@ -19,6 +19,26 @@ const PROTECTED_BRANCHES = new Set(["main", "master"]);
|
|||||||
/** Most status lines `statusCounts` reports by name; the counts stay exact. */
|
/** Most status lines `statusCounts` reports by name; the counts stay exact. */
|
||||||
const STATUS_FILE_CAP = 20;
|
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 }.
|
* 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.
|
* Treats zero exit code as success even if stderr has hints.
|
||||||
@@ -42,6 +62,10 @@ async function git(
|
|||||||
encoding = "utf8",
|
encoding = "utf8",
|
||||||
// stdin (a patch for `apply -`): never a file the dashboard writes.
|
// stdin (a patch for `apply -`): never a file the dashboard writes.
|
||||||
input = null,
|
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
|
// 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";
|
if (literal) pass.GIT_LITERAL_PATHSPECS = "1";
|
||||||
[cmd, argv] = require("./run-as").sudoArgv(member, "git", args, pass);
|
[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 {
|
try {
|
||||||
const run =
|
const run =
|
||||||
@@ -100,7 +130,7 @@ async function git(
|
|||||||
);
|
);
|
||||||
child.stdin.end(input);
|
child.stdin.end(input);
|
||||||
});
|
});
|
||||||
const result = await run(cmd, argv, {
|
const pending = run(cmd, argv, {
|
||||||
cwd,
|
cwd,
|
||||||
env,
|
env,
|
||||||
// A read with its own size cap passes a bigger buffer and treats an
|
// A read with its own size cap passes a bigger buffer and treats an
|
||||||
@@ -113,12 +143,23 @@ async function git(
|
|||||||
timeout,
|
timeout,
|
||||||
encoding,
|
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 };
|
return { stdout: result.stdout, stderr: result.stderr };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// ENOENT from execFile is ambiguous: a missing binary OR a missing cwd.
|
// 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
|
// 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).
|
// 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(
|
const error = new Error(
|
||||||
"git is not installed (or not on the dashboard's PATH), and every git action needs it. " +
|
"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), " +
|
"Install it (sudo apt install git / brew install git / https://git-scm.com/download/win), " +
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const aiTools = require("./openapi-extra/ai-tools");
|
|||||||
const misc = require("./openapi-extra/misc");
|
const misc = require("./openapi-extra/misc");
|
||||||
const auth = require("./openapi-extra/auth");
|
const auth = require("./openapi-extra/auth");
|
||||||
const files = require("./openapi-extra/files");
|
const files = require("./openapi-extra/files");
|
||||||
|
const artifacts = require("./openapi-extra/artifacts");
|
||||||
// Enriched overrides of already-documented endpoints — same operationId, tags,
|
// Enriched overrides of already-documented endpoints — same operationId, tags,
|
||||||
// and request/response `$ref` schemas as the base, with added examples and
|
// and request/response `$ref` schemas as the base, with added examples and
|
||||||
// richer descriptions. Listed last so they win the merge.
|
// richer descriptions. Listed last so they win the merge.
|
||||||
@@ -56,6 +57,7 @@ module.exports = combine(
|
|||||||
misc,
|
misc,
|
||||||
auth,
|
auth,
|
||||||
files,
|
files,
|
||||||
|
artifacts,
|
||||||
overrideSessionsAgents,
|
overrideSessionsAgents,
|
||||||
overrideCore,
|
overrideCore,
|
||||||
overridePricingAlerts,
|
overridePricingAlerts,
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ const schemas = {
|
|||||||
properties: { mode: { type: "string", enum: ["argv", "flag", "after-start"] } },
|
properties: { mode: { type: "string", enum: ["argv", "flag", "after-start"] } },
|
||||||
},
|
},
|
||||||
resume: { type: "boolean", description: "Offers Resume last session" },
|
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" },
|
bypass: { type: "boolean", description: "Offers the per-run Bypass permissions switch" },
|
||||||
proxyCapable: { type: "boolean", description: "Can use an AI Proxy profile" },
|
proxyCapable: { type: "boolean", description: "Can use an AI Proxy profile" },
|
||||||
install: {
|
install: {
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -253,12 +253,40 @@ const paths = {
|
|||||||
remember: { type: "boolean" },
|
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"]
|
["url", "parent"]
|
||||||
),
|
),
|
||||||
responses: { 201: { description: "{lane, created: true, path}" } },
|
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": {
|
"/api/me/git": {
|
||||||
get: {
|
get: {
|
||||||
tags: ["Git identity"],
|
tags: ["Git identity"],
|
||||||
|
|||||||
@@ -504,7 +504,7 @@ const schemas = {
|
|||||||
resumeSessionId: {
|
resumeSessionId: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description:
|
description:
|
||||||
"Optional Claude session id to resume via `--resume`. Must match /^[A-Za-z0-9-]{8,}$/ and requires conversation mode, else EBADSESSION / EBADMODE.",
|
"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",
|
example: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
|
||||||
},
|
},
|
||||||
effort: {
|
effort: {
|
||||||
|
|||||||
+34
-3
@@ -1458,7 +1458,33 @@ function antigravityHookInput(hookType, data) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOOK_TOOLS = { codex: codexHookInput, antigravity: antigravityHookInput };
|
/**
|
||||||
|
* A Grok Build (`grok`) hook. Grok runs the hooks in `~/.claude/settings.json`
|
||||||
|
* itself (its Claude compatibility, on by default), so AgentHub's own Claude
|
||||||
|
* hook reaches here with no `tool` — told apart by the camelCase keys only
|
||||||
|
* Grok sends beside Claude's snake_case aliases (`hookEventName`,
|
||||||
|
* `workspaceRoot`). Its `transcript_path` is Grok's file, never a Claude
|
||||||
|
* transcript, so it is dropped before anything could parse it.
|
||||||
|
*/
|
||||||
|
const isGrokPayload = (data) =>
|
||||||
|
!!data &&
|
||||||
|
typeof data === "object" &&
|
||||||
|
typeof data.hookEventName === "string" &&
|
||||||
|
typeof data.workspaceRoot === "string";
|
||||||
|
|
||||||
|
function grokHookInput(hookType, data) {
|
||||||
|
if (typeof data.session_id !== "string" || !data.session_id) return null;
|
||||||
|
const d = { ...data };
|
||||||
|
delete d.transcript_path;
|
||||||
|
delete d.agent_transcript_path;
|
||||||
|
return { hookType, data: d };
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOOK_TOOLS = {
|
||||||
|
codex: codexHookInput,
|
||||||
|
antigravity: antigravityHookInput,
|
||||||
|
grok: grokHookInput,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record which session a dashboard pane holds. The pane's CLI inherits
|
* Record which session a dashboard pane holds. The pane's CLI inherits
|
||||||
@@ -1524,9 +1550,14 @@ function ingestHook(body, pushSource = null, peer = { kind: "host" }) {
|
|||||||
}
|
}
|
||||||
const ignored = { status: 200, body: { ok: true, ignored: true } };
|
const ignored = { status: 200, body: { ok: true, ignored: true } };
|
||||||
// Which CLI sent it: `codex` from Codex's hooks (codex-hooks.js),
|
// Which CLI sent it: `codex` from Codex's hooks (codex-hooks.js),
|
||||||
// `antigravity` from agy's (antigravity-hooks.js), anything else is Claude
|
// `antigravity` from agy's (antigravity-hooks.js), `grok` for Grok running
|
||||||
|
// the Claude hooks (by its payload's own keys), anything else is Claude
|
||||||
// Code — an allowlist, never a value stored as sent.
|
// Code — an allowlist, never a value stored as sent.
|
||||||
const tool = Object.hasOwn(HOOK_TOOLS, body.tool) ? body.tool : "claude";
|
const tool = Object.hasOwn(HOOK_TOOLS, body.tool)
|
||||||
|
? body.tool
|
||||||
|
: isGrokPayload(data)
|
||||||
|
? "grok"
|
||||||
|
: "claude";
|
||||||
if (tool !== "claude") {
|
if (tool !== "claude") {
|
||||||
if (!data || typeof data !== "object") return ignored;
|
if (!data || typeof data !== "object") return ignored;
|
||||||
require("../lib/hook-auth").dropForeignPaths(peer, data);
|
require("../lib/hook-auth").dropForeignPaths(peer, data);
|
||||||
|
|||||||
@@ -777,6 +777,10 @@ async function cloneInto(req, res, body, before) {
|
|||||||
res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
||||||
return null;
|
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;
|
const once = body.credentials;
|
||||||
if (once != null && (typeof once !== "object" || Array.isArray(once))) {
|
if (once != null && (typeof once !== "object" || Array.isArray(once))) {
|
||||||
res.status(400).json({ error: { code: "EBADCRED", message: "credentials: object" } });
|
res.status(400).json({ error: { code: "EBADCRED", message: "credentials: object" } });
|
||||||
@@ -813,17 +817,32 @@ async function cloneInto(req, res, body, before) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const ownerId = scope.ownerFor(req);
|
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 {
|
try {
|
||||||
await before(target, ownerId);
|
await before(target, ownerId);
|
||||||
await identity.cloneRepo({
|
await identity.cloneRepo({
|
||||||
|
job,
|
||||||
url,
|
url,
|
||||||
parent: dir,
|
parent: dir,
|
||||||
name,
|
name,
|
||||||
once: once
|
once: once
|
||||||
? { username: once.username, password: once.password, remember: once.remember === true }
|
? { username: once.username, password: once.password, remember: once.remember === true }
|
||||||
: null,
|
: null,
|
||||||
|
partial: body.partial === true,
|
||||||
});
|
});
|
||||||
|
queue.finish(job, true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
queue.finish(job, false);
|
||||||
const status = ["EBADCRED", "EBADHOST", "EBADTOOL", "EDISABLED"].includes(err.code)
|
const status = ["EBADCRED", "EBADHOST", "EBADTOOL", "EDISABLED"].includes(err.code)
|
||||||
? 400
|
? 400
|
||||||
: ["ECREDENTIALS", "ESSHAUTH", "ENOREPO", "EOWNERFOLDER", "EMEMBERGONE"].includes(err.code)
|
: ["ECREDENTIALS", "ESSHAUTH", "ENOREPO", "EOWNERFOLDER", "EMEMBERGONE"].includes(err.code)
|
||||||
@@ -843,6 +862,19 @@ async function cloneInto(req, res, body, before) {
|
|||||||
return { target, name, ownerId };
|
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) => {
|
router.post("/clone", sameOriginGuard, async (req, res) => {
|
||||||
const body = req.body || {};
|
const body = req.body || {};
|
||||||
// Before the clone: a workspace this user may not own there, or a tool
|
// Before the clone: a workspace this user may not own there, or a tool
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ const PREF_KEYS = [
|
|||||||
"agent-monitor-notifications",
|
"agent-monitor-notifications",
|
||||||
"agenthub.defaultTool",
|
"agenthub.defaultTool",
|
||||||
"agenthub.permissionMode",
|
"agenthub.permissionMode",
|
||||||
|
"agenthub.codex.sandbox",
|
||||||
|
"agenthub.codex.approval",
|
||||||
|
"agenthub.codex.bypass",
|
||||||
"agenthub.tour",
|
"agenthub.tour",
|
||||||
"agenthub.chatUi",
|
"agenthub.chatUi",
|
||||||
"agenthub.workspace.allTabs",
|
"agenthub.workspace.allTabs",
|
||||||
|
|||||||
+169
-5
@@ -840,6 +840,45 @@ router.get("/:id/transcripts", async (req, res) => {
|
|||||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An omp session's journals are known by path (omp-usage.js): its own,
|
||||||
|
// then one per subagent, each tied to the agent row made from it.
|
||||||
|
if (session.tool === "omp") {
|
||||||
|
const omp = require("../lib/omp-usage");
|
||||||
|
const list = [];
|
||||||
|
if (omp.journalPath(req.params.id)) {
|
||||||
|
list.push({ id: "main", name: "Main Agent", type: "main", has_transcript: true, db_agent_id: null });
|
||||||
|
}
|
||||||
|
for (const j of omp.subagentJournals(req.params.id)) {
|
||||||
|
list.push({
|
||||||
|
id: j.agent,
|
||||||
|
name: j.name,
|
||||||
|
type: "subagent",
|
||||||
|
subagent_type: "task",
|
||||||
|
has_transcript: true,
|
||||||
|
db_agent_id: stmts.getAgent.get(j.rowId) ? j.rowId : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.json({ transcripts: list });
|
||||||
|
}
|
||||||
|
// OpenCode: a subagent is a child session of the store, tied to the agent
|
||||||
|
// row its poller made.
|
||||||
|
if (session.tool === "opencode") {
|
||||||
|
const list = [
|
||||||
|
{ id: "main", name: "Main Agent", type: "main", has_transcript: true, db_agent_id: null },
|
||||||
|
];
|
||||||
|
for (const sub of require("../lib/opencode-usage").subagentSessions(req.params.id)) {
|
||||||
|
list.push({
|
||||||
|
id: sub.id,
|
||||||
|
name: sub.name,
|
||||||
|
type: "subagent",
|
||||||
|
subagent_type: "task",
|
||||||
|
has_transcript: true,
|
||||||
|
db_agent_id: stmts.getAgent.get(sub.rowId) ? sub.rowId : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return res.json({ transcripts: list });
|
||||||
|
}
|
||||||
|
|
||||||
const result = [];
|
const result = [];
|
||||||
|
|
||||||
// Query database agent list for db_agent_id association
|
// Query database agent list for db_agent_id association
|
||||||
@@ -1071,7 +1110,25 @@ router.get("/:id/transcript", async (req, res) => {
|
|||||||
// A Codex session's conversation is its root thread's rollout journal
|
// A Codex session's conversation is its root thread's rollout journal
|
||||||
// (`codex_rollouts`, recorded by codex-usage.js), never a Claude transcript.
|
// (`codex_rollouts`, recorded by codex-usage.js), never a Claude transcript.
|
||||||
const isCodex = session.tool === "codex";
|
const isCodex = session.tool === "codex";
|
||||||
if (isCodex) {
|
// An omp session's (or subagent's) journal, recorded by omp-usage.js.
|
||||||
|
const isOmp = session.tool === "omp";
|
||||||
|
// An OpenCode conversation lives in its SQLite store, not a JSONL file: read
|
||||||
|
// whole (one session's rows), then paged like the files below.
|
||||||
|
if (session.tool === "opencode") {
|
||||||
|
let all = null;
|
||||||
|
try {
|
||||||
|
all = require("../lib/opencode-usage").transcriptMessages(
|
||||||
|
req.params.id,
|
||||||
|
isSubagentFile ? agentId : null
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
all = null;
|
||||||
|
}
|
||||||
|
return res.json(pageMessages(all || [], { limit, afterLine, beforeLine, offset }));
|
||||||
|
}
|
||||||
|
if (isOmp) {
|
||||||
|
jsonlPath = require("../lib/omp-usage").journalPath(req.params.id, agentId || "");
|
||||||
|
} else if (isCodex) {
|
||||||
jsonlPath = db
|
jsonlPath = db
|
||||||
.prepare("SELECT path FROM codex_rollouts WHERE session_id = ? AND thread_id = ?")
|
.prepare("SELECT path FROM codex_rollouts WHERE session_id = ? AND thread_id = ?")
|
||||||
.get(req.params.id, req.params.id)?.path;
|
.get(req.params.id, req.params.id)?.path;
|
||||||
@@ -1105,7 +1162,14 @@ router.get("/:id/transcript", async (req, res) => {
|
|||||||
|
|
||||||
const renders = isCodex
|
const renders = isCodex
|
||||||
? (e) => e.type === "response_item"
|
? (e) => e.type === "response_item"
|
||||||
|
: isOmp
|
||||||
|
? (e) => e.type === "message"
|
||||||
: (e) => TRANSCRIPT_RENDER_TYPES.has(e.type);
|
: (e) => TRANSCRIPT_RENDER_TYPES.has(e.type);
|
||||||
|
const toMessage = isCodex
|
||||||
|
? codexMessage
|
||||||
|
: isOmp
|
||||||
|
? (e, n) => ompMessage(e, n, isSubagentFile)
|
||||||
|
: (e, n) => parseMessage(e, n);
|
||||||
|
|
||||||
// Dedupe state for synthetic rename markers: custom-title lines can repeat
|
// Dedupe state for synthetic rename markers: custom-title lines can repeat
|
||||||
// with the same value across a transcript, so only emit when the title
|
// with the same value across a transcript, so only emit when the title
|
||||||
@@ -1282,7 +1346,7 @@ router.get("/:id/transcript", async (req, res) => {
|
|||||||
}
|
}
|
||||||
if (!renders(entry)) continue;
|
if (!renders(entry)) continue;
|
||||||
|
|
||||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
const message = toMessage(entry, lineNum);
|
||||||
if (!message) continue;
|
if (!message) continue;
|
||||||
total++;
|
total++;
|
||||||
messages.push(message);
|
messages.push(message);
|
||||||
@@ -1313,7 +1377,7 @@ router.get("/:id/transcript", async (req, res) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
const message = toMessage(entry, lineNum);
|
||||||
if (!message) continue;
|
if (!message) continue;
|
||||||
total++;
|
total++;
|
||||||
messages.push(message);
|
messages.push(message);
|
||||||
@@ -1337,7 +1401,7 @@ router.get("/:id/transcript", async (req, res) => {
|
|||||||
}
|
}
|
||||||
if (!renders(entry)) continue;
|
if (!renders(entry)) continue;
|
||||||
|
|
||||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
const message = toMessage(entry, lineNum);
|
||||||
if (!message) continue;
|
if (!message) continue;
|
||||||
total++;
|
total++;
|
||||||
|
|
||||||
@@ -1364,7 +1428,7 @@ router.get("/:id/transcript", async (req, res) => {
|
|||||||
}
|
}
|
||||||
if (!renders(entry)) continue;
|
if (!renders(entry)) continue;
|
||||||
|
|
||||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
const message = toMessage(entry, lineNum);
|
||||||
if (!message) continue;
|
if (!message) continue;
|
||||||
total++;
|
total++;
|
||||||
messages.push(message);
|
messages.push(message);
|
||||||
@@ -1473,6 +1537,106 @@ function codexMessage(entry, num) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One omp journal `message` line → a transcript message in the Claude shape
|
||||||
|
* the client renders, or null. A user line omp wrote for itself (no
|
||||||
|
* `attribution: "user"`) is the system's — in a subagent's journal, the first
|
||||||
|
* one is the orchestrator's task.
|
||||||
|
*/
|
||||||
|
function ompMessage(entry, num, isSubagent = false) {
|
||||||
|
const m = entry.message || {};
|
||||||
|
const at = entry.timestamp || null;
|
||||||
|
const msg = (type, sender, content) => ({ type, sender, timestamp: at, content, line: num });
|
||||||
|
const parts = Array.isArray(m.content)
|
||||||
|
? m.content
|
||||||
|
: typeof m.content === "string"
|
||||||
|
? [{ type: "text", text: m.content }]
|
||||||
|
: [];
|
||||||
|
switch (m.role) {
|
||||||
|
case "user": {
|
||||||
|
const content = parts
|
||||||
|
.filter((c) => c && c.type === "text" && typeof c.text === "string" && c.text.trim())
|
||||||
|
.map((c) => ({ type: "text", text: truncate(c.text, TEXT_MAX) }));
|
||||||
|
if (!content.length) return null;
|
||||||
|
const sender = m.attribution === "user" ? "user" : isSubagent ? "orchestrator" : "system";
|
||||||
|
return msg("user", sender, content);
|
||||||
|
}
|
||||||
|
case "assistant": {
|
||||||
|
const content = [];
|
||||||
|
for (const c of parts) {
|
||||||
|
if (!c) continue;
|
||||||
|
if (c.type === "text" && typeof c.text === "string" && c.text.trim()) {
|
||||||
|
content.push({ type: "text", text: truncate(c.text, TEXT_MAX) });
|
||||||
|
} else if (c.type === "thinking" && typeof c.thinking === "string" && c.thinking.trim()) {
|
||||||
|
content.push({ type: "thinking", text: truncate(c.thinking, 10240) });
|
||||||
|
} else if (c.type === "toolCall") {
|
||||||
|
content.push({
|
||||||
|
type: "tool_use",
|
||||||
|
name: c.name || "unknown",
|
||||||
|
id: c.id || null,
|
||||||
|
input: truncateObj(c.arguments && typeof c.arguments === "object" ? c.arguments : {}, 10240),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!content.length) return null;
|
||||||
|
const out = msg("assistant", "assistant", content);
|
||||||
|
if (m.model) out.model = m.model;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
case "toolResult": {
|
||||||
|
const text = parts
|
||||||
|
.map((c) => (c && c.type === "text" && typeof c.text === "string" ? c.text : ""))
|
||||||
|
.join("");
|
||||||
|
return msg("user", "tool", [
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
id: m.toolCallId || null,
|
||||||
|
output: truncate(text, 10240),
|
||||||
|
is_error: m.isError === true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The transcript route's paging over a conversation already in memory (an
|
||||||
|
* OpenCode store): message N is "line" N, so `after`/`before`/`offset` and the
|
||||||
|
* default latest-`limit` window answer exactly as they do over a file.
|
||||||
|
*/
|
||||||
|
function pageMessages(all, { limit, afterLine, beforeLine, offset }) {
|
||||||
|
const lined = all.map((m, i) => ({ ...m, line: i + 1 }));
|
||||||
|
let picked;
|
||||||
|
let hasMore;
|
||||||
|
if (afterLine !== null) {
|
||||||
|
const rest = lined.filter((m) => m.line > afterLine);
|
||||||
|
picked = rest.slice(0, limit);
|
||||||
|
hasMore = rest.length > limit;
|
||||||
|
} else if (beforeLine !== null) {
|
||||||
|
const older = lined.filter((m) => m.line < beforeLine);
|
||||||
|
picked = older.slice(-limit);
|
||||||
|
hasMore = older.length > limit;
|
||||||
|
} else if (offset > 0) {
|
||||||
|
picked = lined.slice(offset, offset + limit);
|
||||||
|
hasMore = lined.length > offset + limit;
|
||||||
|
} else {
|
||||||
|
picked = lined.slice(-limit);
|
||||||
|
hasMore = lined.length > limit;
|
||||||
|
}
|
||||||
|
const lastLine = picked.length ? picked[picked.length - 1].line : 0;
|
||||||
|
const firstLine = picked.length ? picked[0].line : 0;
|
||||||
|
for (const m of picked) delete m.line;
|
||||||
|
return {
|
||||||
|
messages: picked,
|
||||||
|
total: lined.length,
|
||||||
|
has_more: hasMore,
|
||||||
|
last_line: lastLine,
|
||||||
|
first_line: firstLine,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** The +/- line counts of a Write/Edit/MultiEdit result (`toolUseResult`):
|
/** The +/- line counts of a Write/Edit/MultiEdit result (`toolUseResult`):
|
||||||
* its `structuredPatch` hunks' `+`/`-` lines, or every line of a file it
|
* its `structuredPatch` hunks' `+`/`-` lines, or every line of a file it
|
||||||
* created. Null for any other result. */
|
* created. Null for any other result. */
|
||||||
|
|||||||
Reference in New Issue
Block a user