Compare commits
10 Commits
3237ce4774
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 49f87c4da3 | |||
| 1a6b550e89 | |||
| f8d07dab61 | |||
| 0434ffc750 | |||
| e2d8d314f6 | |||
| 0c8782ea75 | |||
| c042adc51b | |||
| 776ad6466f | |||
| 9c98638ec0 | |||
| aa8ac6cad9 |
+87
-6
@@ -2855,8 +2855,8 @@ const COMMAND_GROUPS = [
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
"accounts",
|
"accounts",
|
||||||
"list|add|remove [name] [--tool codex]",
|
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex]",
|
||||||
"Claude/Codex account pool: new sessions spread over these accounts",
|
"Claude/Codex account pool: new sessions spread over these accounts; pin = one user always on one",
|
||||||
],
|
],
|
||||||
["repl", "", "Open the interactive shell (also: shell, i)"],
|
["repl", "", "Open the interactive shell (also: shell, i)"],
|
||||||
],
|
],
|
||||||
@@ -3814,7 +3814,7 @@ const SUBCOMMANDS = {
|
|||||||
service: ["install", "uninstall", "status"],
|
service: ["install", "uninstall", "status"],
|
||||||
server: ["setup", "off", "status"],
|
server: ["setup", "off", "status"],
|
||||||
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
|
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
|
||||||
accounts: ["list", "add", "remove"],
|
accounts: ["list", "add", "remove", "pin", "unpin"],
|
||||||
pricing: ["set", "delete", "reset"],
|
pricing: ["set", "delete", "reset"],
|
||||||
webhooks: ["test"],
|
webhooks: ["test"],
|
||||||
import: ["rescan", "path"],
|
import: ["rescan", "path"],
|
||||||
@@ -4286,7 +4286,18 @@ async function cmdCodexAccounts(sub, name, flags) {
|
|||||||
}
|
}
|
||||||
pool.resetQuota();
|
pool.resetQuota();
|
||||||
table(
|
table(
|
||||||
["codex account", "kind", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota", "state"],
|
[
|
||||||
|
"codex account",
|
||||||
|
"kind",
|
||||||
|
"live",
|
||||||
|
"$ 5h",
|
||||||
|
"$ 7d",
|
||||||
|
"tok 5h",
|
||||||
|
"tok 7d",
|
||||||
|
"quota",
|
||||||
|
"state",
|
||||||
|
"pinned",
|
||||||
|
],
|
||||||
pool
|
pool
|
||||||
.poolStatus()
|
.poolStatus()
|
||||||
.map((a) => [
|
.map((a) => [
|
||||||
@@ -4301,10 +4312,64 @@ async function cmdCodexAccounts(sub, name, flags) {
|
|||||||
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
|
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
|
||||||
: "–",
|
: "–",
|
||||||
a.full ? "full" : "ok",
|
a.full ? "full" : "ok",
|
||||||
|
pinnedUsers("codex", a.name),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex] —
|
||||||
|
* that user's new sessions run on that account (server/lib/account-pins.js).
|
||||||
|
*/
|
||||||
|
async function cmdAccountPin(sub, positional, tool) {
|
||||||
|
const pins = require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"));
|
||||||
|
const [first, second] = positional;
|
||||||
|
const username = sub === "pin" ? second : first;
|
||||||
|
if (!username || (sub === "pin" && !first)) {
|
||||||
|
throw new Error("usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex]");
|
||||||
|
}
|
||||||
|
const user = require(path.join(REPO_ROOT, "server", "db.js"))
|
||||||
|
.db.prepare("SELECT id FROM users WHERE username = ?")
|
||||||
|
.get(username);
|
||||||
|
if (!user) throw new Error(`no user ${username}`);
|
||||||
|
const pin = pins.setPin(user.id, tool, sub === "pin" ? first : null);
|
||||||
|
if (!require(path.join(REPO_ROOT, "server", "lib", "auth.js")).isServerMode()) {
|
||||||
|
console.log(c.dim(" Pins apply in server mode only."));
|
||||||
|
}
|
||||||
|
if (!pin.account) {
|
||||||
|
console.log(`${c.green("✔")} ${pin.username} unpinned — new ${tool} sessions use the balancer`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const switched = await pins.applyPin(user.id, tool);
|
||||||
|
console.log(`${c.green("✔")} ${pin.username}'s ${tool} sessions run on ${pin.account}`);
|
||||||
|
console.log(c.dim(` ${SWITCH_NOTE[switched] || SWITCH_NOTE["next-session"]}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What `applyPin` did, for the CLI. */
|
||||||
|
const SWITCH_NOTE = {
|
||||||
|
switched: "Switched now: running sessions take it at their next login check, new ones at once.",
|
||||||
|
kept: "Their home already holds it.",
|
||||||
|
"own-login":
|
||||||
|
"They signed in with their own login, which is left alone: no pool login is written over it.",
|
||||||
|
expired: "That login has expired: new sessions fall back to the balancer until it is re-added.",
|
||||||
|
"next-session": "New sessions use it; running ones keep their account until they end.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The `pinned` column: who an admin pinned to `account`. */
|
||||||
|
function pinnedUsers(tool, account) {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"))
|
||||||
|
.listPins(tool)
|
||||||
|
.filter((p) => p.account === account)
|
||||||
|
.map((p) => p.username)
|
||||||
|
.join(",") || "–"
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return "–";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
|
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
|
||||||
async function cmdAccounts(sub, positional, flags = {}) {
|
async function cmdAccounts(sub, positional, flags = {}) {
|
||||||
serverAuth(); // the same settings + database the server reads
|
serverAuth(); // the same settings + database the server reads
|
||||||
@@ -4316,6 +4381,8 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
|||||||
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
|
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
|
||||||
throw new Error("--tool is claude (the default) or codex");
|
throw new Error("--tool is claude (the default) or codex");
|
||||||
}
|
}
|
||||||
|
if (sub === "pin" || sub === "unpin")
|
||||||
|
return cmdAccountPin(sub, positional, flags.tool || "claude");
|
||||||
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
|
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
|
||||||
if (sub === "add") {
|
if (sub === "add") {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -4337,7 +4404,18 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
|||||||
await pool.refreshQuotas({ force: true });
|
await pool.refreshQuotas({ force: true });
|
||||||
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
|
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
|
||||||
table(
|
table(
|
||||||
["account", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota 5h", "quota wk", "state"],
|
[
|
||||||
|
"account",
|
||||||
|
"live",
|
||||||
|
"$ 5h",
|
||||||
|
"$ 7d",
|
||||||
|
"tok 5h",
|
||||||
|
"tok 7d",
|
||||||
|
"quota 5h",
|
||||||
|
"quota wk",
|
||||||
|
"state",
|
||||||
|
"pinned",
|
||||||
|
],
|
||||||
pool
|
pool
|
||||||
.poolStatus()
|
.poolStatus()
|
||||||
.map((a) => [
|
.map((a) => [
|
||||||
@@ -4356,6 +4434,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
|||||||
: a.quota?.error
|
: a.quota?.error
|
||||||
? `no quota (${a.quota.error})`
|
? `no quota (${a.quota.error})`
|
||||||
: "ok",
|
: "ok",
|
||||||
|
pinnedUsers("claude", a.name),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
||||||
@@ -4363,7 +4442,9 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error(
|
console.error(
|
||||||
c.red("✖ Usage: agenthub accounts list | add <name> | remove <name> [--tool codex]")
|
c.red(
|
||||||
|
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex]"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
<Check className="h-3 w-3 flex-shrink-0" />
|
{agents > 0 ? (
|
||||||
|
<Loader2 className="h-3 w-3 flex-shrink-0 animate-spin text-accent" />
|
||||||
|
) : (
|
||||||
|
<Check className="h-3 w-3 flex-shrink-0" />
|
||||||
|
)}
|
||||||
{[
|
{[
|
||||||
t("conversation.turnDone", { time: formatMs(ms) }),
|
t(agents > 0 ? "conversation.turnReplied" : "conversation.turnDone", {
|
||||||
|
time: formatMs(ms),
|
||||||
|
}),
|
||||||
output > 0 && t("conversation.turnTokens", { tokens: fmt(output) }),
|
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 {
|
||||||
|
|||||||
@@ -430,7 +430,7 @@ describe("PaneChat — the unsent prompt", () => {
|
|||||||
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
|
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
|
||||||
expect(screen.getByText("a.ts")).toBeInTheDocument();
|
expect(screen.getByText("a.ts")).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||||
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts", []);
|
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts ", []);
|
||||||
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
|
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -462,7 +462,9 @@ describe("PaneChat — the unsent prompt", () => {
|
|||||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||||
expect(submitToRun).toHaveBeenCalledWith(
|
expect(submitToRun).toHaveBeenCalledWith(
|
||||||
"agenthub-lane-1",
|
"agenthub-lane-1",
|
||||||
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts',
|
// A trailing space: an @ mention at the end would leave claude's file
|
||||||
|
// suggester open, and it takes the Enter.
|
||||||
|
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts ',
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -606,4 +606,26 @@ describe("promptForPane — images apart, every other file in the text", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hands claude every file as an @ mention in one paste, with a trailing space", () => {
|
||||||
|
// A bare pasted path stayed plain text on Windows, and a path in the
|
||||||
|
// command-line prompt is never an image; claude reads an @ mention at submit.
|
||||||
|
const images: PromptImage[] = [
|
||||||
|
{ path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", name: "image.png" },
|
||||||
|
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
|
||||||
|
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
|
||||||
|
{ path: "C:\\Users\\Jo Do\\drops\\0123456789abcdef.png", name: "b.png" },
|
||||||
|
];
|
||||||
|
expect(promptForPane(" look ", images, true)).toEqual({
|
||||||
|
text:
|
||||||
|
'look @C:/Users/u/.claude/agenthub/drops/f326fc666d3bdd92-image.png ' +
|
||||||
|
'@/data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG" ' +
|
||||||
|
'@"C:/Users/Jo Do/drops/0123456789abcdef.png" ',
|
||||||
|
files: [],
|
||||||
|
});
|
||||||
|
expect(promptForPane("just text", [], true)).toEqual({ text: "just text", files: [] });
|
||||||
|
expect(promptWithImages("see", [images[1]!], true)).toBe(
|
||||||
|
"see @/data/drops/0123456789abcdef-log.txt"
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* @file SessionDefaultsSettings — Settings → AI tools → Session defaults: the
|
||||||
|
* account's own starting choices for a new Claude Code session (permission
|
||||||
|
* mode, Bypass included) and a new Codex session (sandbox, approval, bypass),
|
||||||
|
* which the workspace home's prompt box starts from (`lib/sessionDefaults.ts`,
|
||||||
|
* synced per account). Turning bypass on as a default asks first; a pick in
|
||||||
|
* the prompt box still changes only that one session.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ShieldAlert } from "lucide-react";
|
||||||
|
import type { CodexApproval, CodexSandbox } from "../../lib/api";
|
||||||
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
|
import {
|
||||||
|
CODEX_APPROVALS,
|
||||||
|
CODEX_SANDBOXES,
|
||||||
|
DEFAULT_MODES,
|
||||||
|
readCodexDefaults,
|
||||||
|
readDefaultMode,
|
||||||
|
writeCodexDefaults,
|
||||||
|
writeDefaultMode,
|
||||||
|
type DefaultMode,
|
||||||
|
} from "../../lib/sessionDefaults";
|
||||||
|
import { SettingsRow, SettingsSection, SettingsSegmented, SettingsSwitchRow } from "./SettingsRow";
|
||||||
|
|
||||||
|
const MODE_KEYS: Record<DefaultMode, string> = {
|
||||||
|
auto: "Auto",
|
||||||
|
acceptEdits: "AcceptEdits",
|
||||||
|
default: "Default",
|
||||||
|
plan: "Plan",
|
||||||
|
bypassPermissions: "Bypass",
|
||||||
|
};
|
||||||
|
const SANDBOX_KEYS: Record<CodexSandbox, string> = {
|
||||||
|
"read-only": "sandboxReadOnly",
|
||||||
|
"workspace-write": "sandboxWorkspaceWrite",
|
||||||
|
"danger-full-access": "sandboxFullAccess",
|
||||||
|
};
|
||||||
|
const APPROVAL_KEYS: Record<CodexApproval, string> = {
|
||||||
|
"on-request": "approvalOnRequest",
|
||||||
|
never: "approvalNever",
|
||||||
|
};
|
||||||
|
|
||||||
|
function Danger({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<p className="flex items-start gap-2 py-2 text-xs text-status-danger">
|
||||||
|
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionDefaultsSettings() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const aiTools = useAiTools();
|
||||||
|
const shown = (id: string) => aiTools.tools.some((x) => x.id === id && x.enabled);
|
||||||
|
const [mode, setMode] = useState<DefaultMode>(() => readDefaultMode() as DefaultMode);
|
||||||
|
const [codex, setCodex] = useState(readCodexDefaults);
|
||||||
|
|
||||||
|
const askBypass = (tool: string) =>
|
||||||
|
confirmDialog({
|
||||||
|
title:
|
||||||
|
tool === "Codex"
|
||||||
|
? t("sessionDefaults.codexConfirmTitle")
|
||||||
|
: t("sessionDefaults.confirmTitle", { tool }),
|
||||||
|
message: t("sessionDefaults.confirmBody", { tool }),
|
||||||
|
confirmLabel: t("sessionDefaults.confirmLabel"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pickMode = async (m: DefaultMode) => {
|
||||||
|
if (m === "bypassPermissions" && !(await askBypass("Claude Code"))) return;
|
||||||
|
writeDefaultMode(m);
|
||||||
|
setMode(m);
|
||||||
|
};
|
||||||
|
const patchCodex = (patch: Partial<typeof codex>) => {
|
||||||
|
writeCodexDefaults(patch);
|
||||||
|
setCodex((c) => ({ ...c, ...patch }));
|
||||||
|
};
|
||||||
|
const pickCodexBypass = async (on: boolean) => {
|
||||||
|
if (on && !(await askBypass("Codex"))) return;
|
||||||
|
patchCodex({ bypass: on });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!shown("claude") && !shown("codex")) return null;
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
id="session-defaults"
|
||||||
|
title={t("sessionDefaults.title")}
|
||||||
|
description={t("sessionDefaults.description")}
|
||||||
|
>
|
||||||
|
{shown("claude") && (
|
||||||
|
<>
|
||||||
|
<SettingsRow
|
||||||
|
label={t("sessionDefaults.claudeMode")}
|
||||||
|
description={t("sessionDefaults.claudeModeDesc")}
|
||||||
|
control={
|
||||||
|
<SettingsSegmented
|
||||||
|
ariaLabel={t("sessionDefaults.claudeMode")}
|
||||||
|
value={mode}
|
||||||
|
onChange={(m) => void pickMode(m)}
|
||||||
|
options={DEFAULT_MODES.map((m) => ({
|
||||||
|
value: m,
|
||||||
|
label: t(`lanes:home.permission${MODE_KEYS[m]}`),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{mode === "bypassPermissions" && <Danger>{t("run:hint.permissionWarning")}</Danger>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{shown("codex") && (
|
||||||
|
<>
|
||||||
|
<SettingsRow
|
||||||
|
label={t("sessionDefaults.codexSandbox")}
|
||||||
|
control={
|
||||||
|
<SettingsSegmented
|
||||||
|
ariaLabel={t("sessionDefaults.codexSandbox")}
|
||||||
|
value={codex.sandbox}
|
||||||
|
onChange={(sandbox) => patchCodex({ sandbox })}
|
||||||
|
options={CODEX_SANDBOXES.map((v) => ({
|
||||||
|
value: v,
|
||||||
|
label: t(`run:fields.${SANDBOX_KEYS[v]}`),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label={t("sessionDefaults.codexApproval")}
|
||||||
|
control={
|
||||||
|
<SettingsSegmented
|
||||||
|
ariaLabel={t("sessionDefaults.codexApproval")}
|
||||||
|
value={codex.approval}
|
||||||
|
onChange={(approval) => patchCodex({ approval })}
|
||||||
|
options={CODEX_APPROVALS.map((v) => ({
|
||||||
|
value: v,
|
||||||
|
label: t(`run:fields.${APPROVAL_KEYS[v]}`),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsSwitchRow
|
||||||
|
label={t("sessionDefaults.codexBypass")}
|
||||||
|
description={t("sessionDefaults.codexBypassDesc")}
|
||||||
|
checked={codex.bypass}
|
||||||
|
onChange={(on) => void pickCodexBypass(on)}
|
||||||
|
/>
|
||||||
|
{codex.bypass && <Danger>{t("run:hint.bypassWarning", { tool: "Codex" })}</Danger>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* @file SessionDefaultsSettings.test.tsx
|
||||||
|
* @description Session defaults: Bypass becomes Claude's default permission
|
||||||
|
* mode or Codex's default bypass only after the confirm, a cancelled confirm
|
||||||
|
* keeps the old default, the plain modes save at once, and the pet chat's
|
||||||
|
* safe read never returns bypass.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { SessionDefaultsSettings } from "../SessionDefaultsSettings";
|
||||||
|
import { confirmDialog } from "../../../lib/confirm";
|
||||||
|
import { readCodexDefaults, readDefaultMode } from "../../../lib/sessionDefaults";
|
||||||
|
|
||||||
|
const aiTools = vi.hoisted(() => ({
|
||||||
|
tools: [
|
||||||
|
{ id: "claude", label: "Claude Code", installed: true, enabled: true },
|
||||||
|
{ id: "codex", label: "Codex", installed: true, enabled: true },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
vi.mock("../../../hooks/useAiTools", () => ({ useAiTools: () => aiTools }));
|
||||||
|
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn() }));
|
||||||
|
const confirm = vi.mocked(confirmDialog);
|
||||||
|
|
||||||
|
const radio = (name: RegExp) => screen.getAllByRole("radio", { name })[0]!;
|
||||||
|
|
||||||
|
describe("SessionDefaultsSettings", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
confirm.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a plain mode without asking", () => {
|
||||||
|
render(<SessionDefaultsSettings />);
|
||||||
|
fireEvent.click(radio(/plan/i));
|
||||||
|
expect(confirm).not.toHaveBeenCalled();
|
||||||
|
expect(readDefaultMode()).toBe("plan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes Bypass Claude's default only once confirmed", async () => {
|
||||||
|
confirm.mockResolvedValueOnce(false);
|
||||||
|
render(<SessionDefaultsSettings />);
|
||||||
|
fireEvent.click(radio(/bypass/i));
|
||||||
|
await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1));
|
||||||
|
expect(readDefaultMode()).toBe("auto");
|
||||||
|
|
||||||
|
confirm.mockResolvedValueOnce(true);
|
||||||
|
fireEvent.click(radio(/bypass/i));
|
||||||
|
await waitFor(() => expect(readDefaultMode()).toBe("bypassPermissions"));
|
||||||
|
expect(readDefaultMode({ safe: true })).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns Codex's default bypass on only once confirmed, off without asking", async () => {
|
||||||
|
confirm.mockResolvedValueOnce(true);
|
||||||
|
render(<SessionDefaultsSettings />);
|
||||||
|
const sw = screen.getByRole("switch");
|
||||||
|
fireEvent.click(sw);
|
||||||
|
await waitFor(() => expect(readCodexDefaults().bypass).toBe(true));
|
||||||
|
fireEvent.click(screen.getByRole("switch"));
|
||||||
|
await waitFor(() => expect(readCodexDefaults().bypass).toBe(false));
|
||||||
|
expect(confirm).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows nothing when neither Claude nor Codex is enabled", () => {
|
||||||
|
const saved = aiTools.tools;
|
||||||
|
aiTools.tools = [];
|
||||||
|
const { container } = render(<SessionDefaultsSettings />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
aiTools.tools = saved;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,26 +8,31 @@
|
|||||||
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI
|
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI
|
||||||
* API key or a pasted `auth.json` — or removes one, each time with their own
|
* API key or a pasted `auth.json` — or removes one, each time with their own
|
||||||
* password again; an analyst only reads. The secret only goes IN: the server
|
* password again; an analyst only reads. The secret only goes IN: the server
|
||||||
* never sends it back.
|
* never sends it back. An account's Edit (pencil) picks the users who run on
|
||||||
|
* it (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and
|
||||||
|
* a Claude login goes into their home at once — no restart.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
|
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
|
type AccountPin,
|
||||||
type AccountPool,
|
type AccountPool,
|
||||||
type PoolAccountInput,
|
type PoolAccountInput,
|
||||||
type PoolProfile,
|
type PoolProfile,
|
||||||
type PoolTool,
|
type PoolTool,
|
||||||
|
type ServerUserUsage,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { useSpin } from "../../hooks/useSpin";
|
import { useSpin } from "../../hooks/useSpin";
|
||||||
import { ConfirmModal } from "../ConfirmModal";
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
|
import { Checkbox } from "../Checkbox";
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
tool: PoolTool;
|
tool: PoolTool;
|
||||||
@@ -59,6 +64,8 @@ function rowsOf(pool: AccountPool): Row[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pinsOf = (pool: AccountPool, tool: PoolTool): AccountPin[] => pool.pins?.[tool] ?? [];
|
||||||
|
|
||||||
function errorText(t: TFunction<"users">, e: unknown): string {
|
function errorText(t: TFunction<"users">, e: unknown): string {
|
||||||
const err = e as Error & { code?: string };
|
const err = e as Error & { code?: string };
|
||||||
return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" })
|
return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" })
|
||||||
@@ -72,6 +79,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [removing, setRemoving] = useState<Row | null>(null);
|
const [removing, setRemoving] = useState<Row | null>(null);
|
||||||
|
const [editing, setEditing] = useState<Row | null>(null);
|
||||||
|
|
||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
(force = false) =>
|
(force = false) =>
|
||||||
@@ -148,6 +156,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<div className="text-[11px] text-fg-muted">
|
<div className="text-[11px] text-fg-muted">
|
||||||
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||||
</div>
|
</div>
|
||||||
|
{pool && pinsOf(pool, r.tool).some((p) => p.account === r.name) && (
|
||||||
|
<div className="text-[11px] text-fg-secondary">
|
||||||
|
{t("pool.pinned", {
|
||||||
|
users: pinsOf(pool, r.tool)
|
||||||
|
.filter((p) => p.account === r.name)
|
||||||
|
.map((p) => p.username)
|
||||||
|
.join(", "),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
||||||
{t(`pool.kind.${r.kind}`)}
|
{t(`pool.kind.${r.kind}`)}
|
||||||
@@ -188,7 +206,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<td className="px-5 py-2 text-right">
|
<td className="px-5 py-2 text-right whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(r)}
|
||||||
|
title={t("pool.edit", { name: r.name })}
|
||||||
|
aria-label={t("pool.edit", { name: r.name })}
|
||||||
|
className="btn-icon"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setRemoving(r)}
|
onClick={() => setRemoving(r)}
|
||||||
@@ -206,7 +233,6 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{adding && (
|
{adding && (
|
||||||
<AddAccountDialog
|
<AddAccountDialog
|
||||||
onClose={() => setAdding(false)}
|
onClose={() => setAdding(false)}
|
||||||
@@ -228,10 +254,163 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{editing && pool && (
|
||||||
|
<EditAccountDialog
|
||||||
|
pool={pool}
|
||||||
|
row={editing}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
onDone={(r) => {
|
||||||
|
const left = r.added.filter(
|
||||||
|
(u) => u.switched === "own-login" || u.switched === "expired"
|
||||||
|
);
|
||||||
|
toast.success(t("pool.editDone", { name: editing.name }));
|
||||||
|
for (const u of left) {
|
||||||
|
toast.info(
|
||||||
|
t(`pool.pinDone.${u.switched}`, { user: u.username, account: editing.name })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setEditing(null);
|
||||||
|
void load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One account's Edit: tick the users whose sessions run on it. A user already
|
||||||
|
* on another account of the tool moves here when ticked; unticking one sends
|
||||||
|
* them back to the least-used account. Saved in one request, password again.
|
||||||
|
*/
|
||||||
|
function EditAccountDialog({
|
||||||
|
pool,
|
||||||
|
row,
|
||||||
|
onClose,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
pool: AccountPool;
|
||||||
|
row: Row;
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: (r: Awaited<ReturnType<typeof api.accountPool.setUsers>>) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const pins = pinsOf(pool, row.tool);
|
||||||
|
const initial = pins.filter((p) => p.account === row.name).map((p) => p.userId);
|
||||||
|
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||||
|
const [chosen, setChosen] = useState<Set<number>>(() => new Set(initial));
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.users
|
||||||
|
.list()
|
||||||
|
.then((r) => setUsers(r.users))
|
||||||
|
.catch(() => setUsers([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
|
||||||
|
const ready = changed && current.length > 0;
|
||||||
|
const toggle = (id: number, on: boolean) =>
|
||||||
|
setChosen((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (on) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!ready || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
onDone(await api.accountPool.setUsers(row.tool, row.name, [...chosen], current));
|
||||||
|
} catch (e) {
|
||||||
|
setError(errorText(t, e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmModal
|
||||||
|
open
|
||||||
|
title={t("pool.edit", { name: row.name })}
|
||||||
|
message={t("pool.editHint")}
|
||||||
|
confirmLabel={t("pool.save")}
|
||||||
|
cancelLabel={t("common:cancel")}
|
||||||
|
destructive={false}
|
||||||
|
busy={busy}
|
||||||
|
disabled={!ready}
|
||||||
|
onConfirm={() => void submit()}
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mt-3 space-y-3"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.target as HTMLElement).tagName === "INPUT" && isSubmitEnter(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-medium text-fg-secondary">{t("pool.users")}</span>
|
||||||
|
<div className="mt-1 max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||||
|
{users === null && (
|
||||||
|
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
|
||||||
|
)}
|
||||||
|
{users?.length === 0 && (
|
||||||
|
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
|
||||||
|
)}
|
||||||
|
{users?.map((u) => {
|
||||||
|
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
|
||||||
|
return (
|
||||||
|
<div key={u.id} className="px-3 py-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={chosen.has(u.id)}
|
||||||
|
onChange={(on) => toggle(u.id, on)}
|
||||||
|
label={
|
||||||
|
<span>
|
||||||
|
{u.username}
|
||||||
|
{u.disabled && (
|
||||||
|
<span className="ml-1 text-fg-muted">({t("disabled")})</span>
|
||||||
|
)}
|
||||||
|
{elsewhere && (
|
||||||
|
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||||
|
{t("pool.onAccount", { account: elsewhere.account })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
labelClassName="text-sm text-fg-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input w-full text-sm"
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
aria-label={t("yourPassword")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ConfirmModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type ClaudeSource = "profile" | "credentials" | "token";
|
type ClaudeSource = "profile" | "credentials" | "token";
|
||||||
|
|
||||||
function AddAccountDialog({
|
function AddAccountDialog({
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
* admin adds an account only with their own password typed (the token goes
|
* admin adds an account only with their own password typed (the token goes
|
||||||
* in once and is not shown back) — a saved login moved in (the server's own
|
* in once and is not shown back) — a saved login moved in (the server's own
|
||||||
* included), or a setup-token — and removes one the same way, and an analyst
|
* included), or a setup-token — and removes one the same way, and an analyst
|
||||||
* sees no add or remove control.
|
* sees no add or remove control. An account's Edit ticks the users who run on
|
||||||
|
* it, saved with the admin's own password; the users show on the account.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -19,10 +20,12 @@ const accountPool = vi.hoisted(() => ({
|
|||||||
add: vi.fn(),
|
add: vi.fn(),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
profiles: vi.fn(),
|
profiles: vi.fn(),
|
||||||
|
setUsers: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
const users = vi.hoisted(() => ({ list: vi.fn() }));
|
||||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<Record<string, unknown>>();
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
return { ...actual, api: { accountPool } };
|
return { ...actual, api: { accountPool, users } };
|
||||||
});
|
});
|
||||||
|
|
||||||
import { AccountPoolCard } from "../AccountPoolCard";
|
import { AccountPoolCard } from "../AccountPoolCard";
|
||||||
@@ -66,6 +69,12 @@ const POOL: AccountPool = {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
for (const fn of Object.values(accountPool)) fn.mockReset();
|
for (const fn of Object.values(accountPool)) fn.mockReset();
|
||||||
accountPool.list.mockResolvedValue(POOL);
|
accountPool.list.mockResolvedValue(POOL);
|
||||||
|
users.list.mockResolvedValue({
|
||||||
|
users: [
|
||||||
|
{ id: 7, username: "mia", disabled: false },
|
||||||
|
{ id: 8, username: "leo", disabled: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
accountPool.profiles.mockResolvedValue({
|
accountPool.profiles.mockResolvedValue({
|
||||||
profiles: [
|
profiles: [
|
||||||
{
|
{
|
||||||
@@ -158,4 +167,43 @@ describe("AccountPoolCard", () => {
|
|||||||
expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1", "boss-pass")
|
expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1", "boss-pass")
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets who runs on an account from its Edit, with the admin's own password", async () => {
|
||||||
|
accountPool.list.mockResolvedValue({
|
||||||
|
...POOL,
|
||||||
|
pins: {
|
||||||
|
claude: [
|
||||||
|
{ userId: 8, username: "leo", account: "team-1" },
|
||||||
|
{ userId: 7, username: "mia", account: "team-0" },
|
||||||
|
],
|
||||||
|
codex: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
accountPool.setUsers.mockResolvedValue({
|
||||||
|
added: [{ userId: 7, username: "mia", switched: "switched" }],
|
||||||
|
removed: [{ userId: 8, username: "leo" }],
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AccountPoolCard isAdmin />);
|
||||||
|
expect(await screen.findByText("Used by: leo")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /switch a user/i })).toBeNull();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Edit team-1" }));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
const leo = await screen.findByRole("checkbox", { name: /leo/ });
|
||||||
|
const mia = screen.getByRole("checkbox", { name: /mia/ });
|
||||||
|
expect(leo).toHaveAttribute("aria-checked", "true");
|
||||||
|
expect(mia).toHaveAttribute("aria-checked", "false");
|
||||||
|
expect(screen.getByText("now on team-0")).toBeInTheDocument();
|
||||||
|
await user.click(mia);
|
||||||
|
await user.click(leo);
|
||||||
|
const save = Array.from(dialog.querySelectorAll("button")).find(
|
||||||
|
(b) => b.textContent === "Save"
|
||||||
|
)!;
|
||||||
|
expect(save).toBeDisabled();
|
||||||
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||||
|
await user.click(save);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7], "boss-pass")
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -166,7 +166,8 @@
|
|||||||
"EACTIVE": "This server's Claude Code keeps that login in the macOS Keychain. Switch it to another profile first.",
|
"EACTIVE": "This server's Claude Code keeps that login in the macOS Keychain. Switch it to another profile first.",
|
||||||
"ENOTFOUND": "That saved login no longer exists.",
|
"ENOTFOUND": "That saved login no longer exists.",
|
||||||
"ESTALE": "That login was already refreshed elsewhere. Log in again and save it anew.",
|
"ESTALE": "That login was already refreshed elsewhere. Log in again and save it anew.",
|
||||||
"EUPSTREAM": "Anthropic could not be reached. Try again."
|
"EUPSTREAM": "Anthropic could not be reached. Try again.",
|
||||||
|
"ENOUSER": "That user no longer exists."
|
||||||
},
|
},
|
||||||
"source": "From",
|
"source": "From",
|
||||||
"sources": {
|
"sources": {
|
||||||
@@ -196,7 +197,20 @@
|
|||||||
"token": "The sk-ant-… that `claude setup-token` printed.",
|
"token": "The sk-ant-… that `claude setup-token` printed.",
|
||||||
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
|
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
|
||||||
},
|
},
|
||||||
"refreshFailed": "refresh failed"
|
"refreshFailed": "refresh failed",
|
||||||
|
"pinned": "Used by: {{users}}",
|
||||||
|
"loadingUsers": "Loading…",
|
||||||
|
"pinDone": {
|
||||||
|
"own-login": "{{user}} signed in with their own login, which was left alone",
|
||||||
|
"expired": "{{account}} has expired — {{user}}'s new sessions fall back to the least-used account"
|
||||||
|
},
|
||||||
|
"edit": "Edit {{name}}",
|
||||||
|
"editHint": "Tick the users whose sessions run on this account instead of the least-used one — even when it is full. A user on another account moves here. A Claude login is written into their home at once: sessions already running move to it at their next login check, with no restart. A setup-token or Codex account applies to new sessions. Untick to send a user back to the least-used account.",
|
||||||
|
"save": "Save",
|
||||||
|
"users": "Users on this account",
|
||||||
|
"noUsers": "No users yet.",
|
||||||
|
"onAccount": "now on {{account}}",
|
||||||
|
"editDone": "{{name}} saved"
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"button": "Delete user",
|
"button": "Delete user",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -166,7 +166,8 @@
|
|||||||
"EACTIVE": "Claude Code của server giữ login này trong macOS Keychain. Hãy chuyển nó sang profile khác trước.",
|
"EACTIVE": "Claude Code của server giữ login này trong macOS Keychain. Hãy chuyển nó sang profile khác trước.",
|
||||||
"ENOTFOUND": "Login đã lưu đó không còn nữa.",
|
"ENOTFOUND": "Login đã lưu đó không còn nữa.",
|
||||||
"ESTALE": "Login này đã được làm mới ở nơi khác. Đăng nhập lại và lưu lại.",
|
"ESTALE": "Login này đã được làm mới ở nơi khác. Đăng nhập lại và lưu lại.",
|
||||||
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại."
|
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại.",
|
||||||
|
"ENOUSER": "User đó không còn tồn tại."
|
||||||
},
|
},
|
||||||
"source": "Nguồn",
|
"source": "Nguồn",
|
||||||
"sources": {
|
"sources": {
|
||||||
@@ -196,7 +197,20 @@
|
|||||||
"token": "Chuỗi sk-ant-… mà `claude setup-token` in ra.",
|
"token": "Chuỗi sk-ant-… mà `claude setup-token` in ra.",
|
||||||
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
|
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
|
||||||
},
|
},
|
||||||
"refreshFailed": "làm mới lỗi"
|
"refreshFailed": "làm mới lỗi",
|
||||||
|
"pinned": "Đang dùng: {{users}}",
|
||||||
|
"loadingUsers": "Đang tải…",
|
||||||
|
"pinDone": {
|
||||||
|
"own-login": "{{user}} đang dùng login riêng của họ, nên không bị ghi đè",
|
||||||
|
"expired": "{{account}} đã hết hạn — phiên mới của {{user}} quay về tài khoản ít tải nhất"
|
||||||
|
},
|
||||||
|
"edit": "Sửa {{name}}",
|
||||||
|
"editHint": "Tick các user mà phiên của họ chạy trên tài khoản này thay vì tài khoản ít tải nhất — kể cả khi nó đã đầy. User đang ở tài khoản khác sẽ chuyển sang đây. Login Claude được ghi ngay vào home của họ: phiên đang chạy chuyển sang ở lần kiểm tra đăng nhập kế tiếp, không cần khởi động lại. Setup-token hoặc tài khoản Codex áp dụng cho phiên mới. Bỏ tick để trả user về tài khoản ít tải nhất.",
|
||||||
|
"save": "Lưu",
|
||||||
|
"users": "User dùng tài khoản này",
|
||||||
|
"noUsers": "Chưa có user nào.",
|
||||||
|
"onAccount": "đang ở {{account}}",
|
||||||
|
"editDone": "Đã lưu {{name}}"
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"button": "Xoá user",
|
"button": "Xoá user",
|
||||||
|
|||||||
+60
-1
@@ -676,9 +676,18 @@ export type CodexPoolAccount = {
|
|||||||
quotaPercent: number | null;
|
quotaPercent: number | null;
|
||||||
full: boolean;
|
full: boolean;
|
||||||
};
|
};
|
||||||
|
/** A user an admin pinned to one pool account: their new sessions skip the balancer. */
|
||||||
|
export type AccountPin = {
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
account: string;
|
||||||
|
pinnedAt?: string;
|
||||||
|
};
|
||||||
export type AccountPool = {
|
export type AccountPool = {
|
||||||
accounts: PoolAccount[];
|
accounts: PoolAccount[];
|
||||||
codex: CodexPoolAccount[];
|
codex: CodexPoolAccount[];
|
||||||
|
/** Per tool; absent from an older server, empty in local mode. */
|
||||||
|
pins?: { claude: AccountPin[]; codex: AccountPin[] };
|
||||||
blockPercent: number;
|
blockPercent: number;
|
||||||
};
|
};
|
||||||
export type PoolTool = "claude" | "codex";
|
export type PoolTool = "claude" | "codex";
|
||||||
@@ -857,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`). */
|
||||||
@@ -1052,6 +1064,20 @@ export const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ ...body, currentPassword }),
|
body: JSON.stringify({ ...body, currentPassword }),
|
||||||
}),
|
}),
|
||||||
|
/** PUT /api/accounts/:name/users — admin: exactly these users run on this account. */
|
||||||
|
setUsers: (tool: PoolTool, name: string, userIds: number[], currentPassword: string) =>
|
||||||
|
request<{
|
||||||
|
/** Users moved onto it; `switched`: what happened now (a Claude login goes into their home). */
|
||||||
|
added: {
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
switched: "switched" | "kept" | "own-login" | "expired" | "next-session";
|
||||||
|
}[];
|
||||||
|
removed: { userId: number; username: string }[];
|
||||||
|
}>(`/accounts/${encodeURIComponent(name)}/users`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ tool, userIds, currentPassword }),
|
||||||
|
}),
|
||||||
/** DELETE /api/accounts/:name — admin, with their OWN password again. */
|
/** DELETE /api/accounts/:name — admin, with their OWN password again. */
|
||||||
remove: (tool: PoolTool, name: string, currentPassword: string) =>
|
remove: (tool: PoolTool, name: string, currentPassword: string) =>
|
||||||
request<{ ok: true }>(`/accounts/${encodeURIComponent(name)}`, {
|
request<{ ok: true }>(`/accounts/${encodeURIComponent(name)}`, {
|
||||||
@@ -3550,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.
|
||||||
@@ -3951,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`, {
|
||||||
@@ -4406,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. */
|
||||||
@@ -5189,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
|
||||||
@@ -5262,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.9 (rebuild client to match)
|
UI build v2.8.12 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+34
-7
@@ -89,9 +89,9 @@ In server mode a **developer** sees only rows of the workspaces they own (`lanes
|
|||||||
|
|
||||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||||
|
|
||||||
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], blockPercent}` — `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
|
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
|
||||||
|
|
||||||
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`. `GET /api/accounts/profiles` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name.
|
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`. `GET /api/accounts/profiles` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds, currentPassword}` → `{added: [{userId, username, switched}], removed: [{userId, username}]}` is the Users page's Edit of one account: exactly those users run on it afterwards (a user on another account moves here, one left out goes back to the balancer; one transaction, each change audited; `400 EBADUSER`, `404 ENOUSER`/`ENOACCOUNT`). Removing an account drops its pins; deleting a user drops theirs.
|
||||||
|
|
||||||
**The account's own preferences and first-run setup** (`server/routes/me.js`; local mode is the one account `local`, server mode one row per account in `user_prefs`):
|
**The account's own preferences and first-run setup** (`server/routes/me.js`; local mode is the one account `local`, server mode one row per account in `user_prefs`):
|
||||||
|
|
||||||
@@ -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`.
|
||||||
|
|||||||
+1
-1
@@ -108,7 +108,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it
|
|||||||
| `agenthub server install-system [--dry-run\|--uninstall]` | **One Linux user per account** (S3). Run as the account that runs the dashboard, never as root: it runs `scripts/server/install-system.sh` through one `sudo`. That script creates the groups, `/srv/agenthub/users`, `/opt/agenthub`, the sudoers file, the member hooks and `hidepid`, and installs the system unit `agenthub.service`. The command then points the unit at this install, writes `AGENTHUB_USERS_ROOT`, and moves the dashboard from the `systemd --user` unit to the system one (panes stay). `--dry-run` prints the sudo command; `--uninstall` goes back to the user unit. See [SERVER-MODE.md](SERVER-MODE.md#one-linux-user-per-account) |
|
| `agenthub server install-system [--dry-run\|--uninstall]` | **One Linux user per account** (S3). Run as the account that runs the dashboard, never as root: it runs `scripts/server/install-system.sh` through one `sudo`. That script creates the groups, `/srv/agenthub/users`, `/opt/agenthub`, the sudoers file, the member hooks and `hidepid`, and installs the system unit `agenthub.service`. The command then points the unit at this install, writes `AGENTHUB_USERS_ROOT`, and moves the dashboard from the `systemd --user` unit to the system one (panes stay). `--dry-run` prints the sudo command; `--uninstall` goes back to the user unit. See [SERVER-MODE.md](SERVER-MODE.md#one-linux-user-per-account) |
|
||||||
| `agenthub server tools` | One Linux user per account: publish this install's scripts and every installed AI tool under `/opt/agenthub` (`tools/bin/<command>`), where every account's pane runs them. The server also does this at start and after each install or update. Prints each tool's shared copy, or why it could not be shared |
|
| `agenthub server tools` | One Linux user per account: publish this install's scripts and every installed AI tool under `/opt/agenthub` (`tools/bin/<command>`), where every account's pane runs them. The server also does this at start and after each install or update. Prints each tool's shared copy, or why it could not be shared |
|
||||||
| `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
|
| `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
|
||||||
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ and tokens in the last 5 h / 7 d, and quota. With `--tool codex` the same for the **Codex account pool**: `add <name> --tool codex --api-key` (key typed hidden or on stdin) or `--from <dir>` (a `codex login` home or its `auth.json`). See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
|
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ and tokens in the last 5 h / 7 d, and quota. With `--tool codex` the same for the **Codex account pool**: `add <name> --tool codex --api-key` (key typed hidden or on stdin) or `--from <dir>` (a `codex login` home or its `auth.json`). `pin <account> <user>` / `unpin <user>` (add `--tool codex` for Codex) switches one user's sessions to that account (a Claude login goes into their home at once, running sessions follow without a restart) (server mode; `list` shows a `pinned` column). See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
|
||||||
| `agenthub users list\|add <name> [--role admin\|analyst\|member]\|passwd <name>\|role <name> <role>\|disable <name>\|enable <name>\|provision <name>\|--all\|assign <workspace id> <name>\|audit [--limit N]` | Server mode's accounts, written straight to the database. Once `install-system` ran, `add` first creates the Linux user `ah-<name>`; a name must then be lower case: a letter, then up to 28 letters, digits, `_` or `-`. `disable`/`enable` lock or unlock that Linux user too, and `provision` gives users made earlier their Linux user (idempotent); `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of members' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin |
|
| `agenthub users list\|add <name> [--role admin\|analyst\|member]\|passwd <name>\|role <name> <role>\|disable <name>\|enable <name>\|provision <name>\|--all\|assign <workspace id> <name>\|audit [--limit N]` | Server mode's accounts, written straight to the database. Once `install-system` ran, `add` first creates the Linux user `ah-<name>`; a name must then be lower case: a letter, then up to 28 letters, digits, `_` or `-`. `disable`/`enable` lock or unlock that Linux user too, and `provision` gives users made earlier their Linux user (idempotent); `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of members' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin |
|
||||||
|
|
||||||
### Interactive REPL
|
### Interactive REPL
|
||||||
|
|||||||
@@ -621,6 +621,7 @@ Server mode's accounts ([SERVER-MODE.md](SERVER-MODE.md), `server/lib/auth.js`).
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `users` | `id`, `username` (unique, case-insensitive), `role` (`admin`\|`analyst`\|`member`), `password_hash` (`scrypt$<salt>$<hash>`), `disabled`, `created_at` |
|
| `users` | `id`, `username` (unique, case-insensitive), `role` (`admin`\|`analyst`\|`member`), `password_hash` (`scrypt$<salt>$<hash>`), `disabled`, `created_at` |
|
||||||
| `auth_sessions` | `token_hash` (SHA-256 of the cookie token, PK), `user_id` → `users.id` (cascade), `created_at`, `expires_at` (30 days) |
|
| `auth_sessions` | `token_hash` (SHA-256 of the cookie token, PK), `user_id` → `users.id` (cascade), `created_at`, `expires_at` (30 days) |
|
||||||
|
| `account_pins` | `user_id` → `users.id` (cascade), `tool` (`claude`\|`codex`), `account` (a pool account's name), `pinned_at`; PK (`user_id`, `tool`) — an admin's pin of a user to one pool account (`server/lib/account-pins.js`) |
|
||||||
|
|
||||||
`dashboard_runs.account` names the Claude pool account a run's pane was started on (`server/lib/claude-pool.js`; `NULL` = the machine's own login). The balancer sums each account's spend from the `token_usage_hourly` rows of those runs' sessions. The pool's tokens live in files under `<CLAUDE_HOME>/agenthub-accounts/`, never in the database.
|
`dashboard_runs.account` names the Claude pool account a run's pane was started on (`server/lib/claude-pool.js`; `NULL` = the machine's own login). The balancer sums each account's spend from the `token_usage_hourly` rows of those runs' sessions. The pool's tokens live in files under `<CLAUDE_HOME>/agenthub-accounts/`, never in the database.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ claude setup-token
|
|||||||
agenthub accounts add team-1
|
agenthub accounts add team-1
|
||||||
agenthub accounts list # live panes, $ and tokens in the last 5 h / 7 d, quota, state
|
agenthub accounts list # live panes, $ and tokens in the last 5 h / 7 d, quota, state
|
||||||
agenthub accounts remove team-1
|
agenthub accounts remove team-1
|
||||||
|
agenthub accounts pin team-1 mia # switch mia to team-1 (unpin mia = automatic again)
|
||||||
```
|
```
|
||||||
|
|
||||||
In server mode an admin can do the same on the **Users** page, in the
|
In server mode an admin can do the same on the **Users** page, in the
|
||||||
@@ -423,6 +424,28 @@ How a new session picks its account (`server/lib/claude-pool.js`):
|
|||||||
pane still needs that member's own `/login`.
|
pane still needs that member's own `/login`.
|
||||||
- A token more than a year old is skipped.
|
- A token more than a year old is skipped.
|
||||||
- A resumed session picks an account again, like a new one.
|
- A resumed session picks an account again, like a new one.
|
||||||
|
- **Switching a user to one account.** An admin can take one user off the
|
||||||
|
ranking: the pencil (**Edit**) on that account's row in the Account pool
|
||||||
|
card — tick the users who run on it, untick to send one back to automatic (or
|
||||||
|
`agenthub accounts pin <account> <user> [--tool codex]`, `unpin <user>`; their
|
||||||
|
own password again, each change audited). A user ticked on one account
|
||||||
|
moves off any other. Every session in a
|
||||||
|
workspace that user OWNS then runs on that account, even when it is full;
|
||||||
|
other users are not affected, and the account is not reserved for them.
|
||||||
|
- **A Claude login switches at once, without a restart.** The server first
|
||||||
|
copies every home's newer pair back into the pool (so the login leaving
|
||||||
|
the home keeps its only live refresh token), then writes the new login
|
||||||
|
into the user's `~/.claude/.credentials.json` as them. New sessions start
|
||||||
|
on it; sessions already running keep their process and move to it the
|
||||||
|
next time Claude Code re-reads its credentials (it does so before it
|
||||||
|
refreshes its access token, so at the latest when that token expires).
|
||||||
|
A login the user made themselves (`/login`) is never written over — the
|
||||||
|
card says so. Their sessions are booked to the account they started on.
|
||||||
|
- A setup-token, a Codex account, or a host pane gets the account at its
|
||||||
|
next spawn; running ones keep theirs.
|
||||||
|
- An expired account falls back to the ranking. Removing the account drops
|
||||||
|
the switch; deleting the user drops theirs. Server mode only
|
||||||
|
(`server/lib/account-pins.js`).
|
||||||
- Quota is read with each account's own token, in the background, at most every
|
- Quota is read with each account's own token, in the background, at most every
|
||||||
5 minutes. If the usage endpoint refuses a setup-token, the state column shows
|
5 minutes. If the usage endpoint refuses a setup-token, the state column shows
|
||||||
`no quota (…)` and ranking uses spend alone.
|
`no quota (…)` and ranking uses spend alone.
|
||||||
@@ -447,6 +470,7 @@ CODEX_HOME=$(mktemp -d) codex login
|
|||||||
agenthub accounts add team-chatgpt --tool codex --from /tmp/tmp.XXXX
|
agenthub accounts add team-chatgpt --tool codex --from /tmp/tmp.XXXX
|
||||||
agenthub accounts list --tool codex
|
agenthub accounts list --tool codex
|
||||||
agenthub accounts remove team-key --tool codex
|
agenthub accounts remove team-key --tool codex
|
||||||
|
agenthub accounts pin team-key mia --tool codex # pins work as for Claude
|
||||||
```
|
```
|
||||||
|
|
||||||
- **API key**: any number of panes at once. The pane gets the key through
|
- **API key**: any number of panes at once. The pane gets the key through
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+821
-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.9
|
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
|
||||||
@@ -4785,6 +4792,22 @@ components:
|
|||||||
description: Events attributed to this run's inner agents, ordered by created_at then id. Capped at 5000 rows.
|
description: Events attributed to this run's inner agents, ordered by created_at then id. Capped at 5000 rows.
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/DashboardEvent'
|
$ref: '#/components/schemas/DashboardEvent'
|
||||||
|
AccountPin:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- userId
|
||||||
|
- username
|
||||||
|
- account
|
||||||
|
properties:
|
||||||
|
userId:
|
||||||
|
type: integer
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
account:
|
||||||
|
type: string
|
||||||
|
description: The pool account's name
|
||||||
|
pinnedAt:
|
||||||
|
type: string
|
||||||
UserPrefs:
|
UserPrefs:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -4912,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:
|
||||||
@@ -15141,6 +15229,18 @@ paths:
|
|||||||
nullable: true
|
nullable: true
|
||||||
full:
|
full:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
pins:
|
||||||
|
type: object
|
||||||
|
description: Per tool, the users an admin pinned to one account (empty in local mode)
|
||||||
|
properties:
|
||||||
|
claude:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/AccountPin'
|
||||||
|
codex:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/AccountPin'
|
||||||
codex:
|
codex:
|
||||||
type: array
|
type: array
|
||||||
description: The Codex account pool (server/lib/codex-pool.js), same fields plus `kind`; no expiry
|
description: The Codex account pool (server/lib/codex-pool.js), same fields plus `kind`; no expiry
|
||||||
@@ -15241,6 +15341,62 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/api/accounts/pins:
|
||||||
|
put:
|
||||||
|
tags:
|
||||||
|
- Auth
|
||||||
|
operationId: pinPoolAccount
|
||||||
|
summary: 'Admin: switch one user''s sessions of a tool to one pool account (`account: null` = automatic), with their own password again. A Claude login is written into their home at once; running panes take it without a restart'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- tool
|
||||||
|
- userId
|
||||||
|
- currentPassword
|
||||||
|
properties:
|
||||||
|
tool:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- claude
|
||||||
|
- codex
|
||||||
|
userId:
|
||||||
|
type: integer
|
||||||
|
account:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
currentPassword:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: '`{pin: {userId, username, account}, switched}` — `account` null when unpinned; `switched`: switched | kept | own-login | expired | next-session | null'
|
||||||
|
'400':
|
||||||
|
description: EBADTOOL
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'403':
|
||||||
|
description: EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'404':
|
||||||
|
description: ENOUSER, ENOACCOUNT
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'429':
|
||||||
|
description: ETOOMANY — too many wrong passwords
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
/api/accounts/profiles:
|
/api/accounts/profiles:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -15250,6 +15406,67 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: '`{profiles: [{name, label, email, subscriptionType, active}]}`'
|
description: '`{profiles: [{name, label, email, subscriptionType, active}]}`'
|
||||||
|
/api/accounts/{name}/users:
|
||||||
|
put:
|
||||||
|
tags:
|
||||||
|
- Auth
|
||||||
|
operationId: setPoolAccountUsers
|
||||||
|
summary: 'Admin: exactly these users run on this pool account (a user on another account moves here; one left out goes back to the balancer), with their own password again. A Claude login is written into each added user''s home at once'
|
||||||
|
parameters:
|
||||||
|
- name: name
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- tool
|
||||||
|
- userIds
|
||||||
|
- currentPassword
|
||||||
|
properties:
|
||||||
|
tool:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- claude
|
||||||
|
- codex
|
||||||
|
userIds:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
currentPassword:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: '`{added: [{userId, username, switched}], removed: [{userId, username}]}` — `switched`: switched | kept | own-login | expired | next-session'
|
||||||
|
'400':
|
||||||
|
description: EBADTOOL, EBADUSER
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'403':
|
||||||
|
description: EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'404':
|
||||||
|
description: ENOUSER, ENOACCOUNT
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'429':
|
||||||
|
description: ETOOMANY — too many wrong passwords
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
/api/accounts/{name}:
|
/api/accounts/{name}:
|
||||||
delete:
|
delete:
|
||||||
tags:
|
tags:
|
||||||
@@ -16145,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:
|
||||||
@@ -16346,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
+3
-5
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.9",
|
"version": "2.8.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.9",
|
"version": "2.8.12",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"adm-zip": "^0.6.1",
|
"adm-zip": "^0.6.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
"multer": "^2.0.0",
|
"multer": "^2.0.0",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"systray2": "^2.1.4",
|
"systray2": "^2.1.4",
|
||||||
@@ -27,7 +28,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"js-yaml": "^4.1.0",
|
|
||||||
"prettier": "^3.8.1"
|
"prettier": "^3.8.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -215,7 +215,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/array-flatten": {
|
"node_modules/array-flatten": {
|
||||||
@@ -1099,7 +1098,6 @@
|
|||||||
"version": "4.3.2",
|
"version": "4.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.9",
|
"version": "2.8.12",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
@@ -75,6 +75,7 @@
|
|||||||
"adm-zip": "^0.6.1",
|
"adm-zip": "^0.6.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
"multer": "^2.0.0",
|
"multer": "^2.0.0",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"systray2": "^2.1.4",
|
"systray2": "^2.1.4",
|
||||||
@@ -88,7 +89,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"js-yaml": "^4.1.0",
|
|
||||||
"prettier": "^3.8.1"
|
"prettier": "^3.8.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
* same account; removing the account takes it out of those homes; and the
|
* same account; removing the account takes it out of those homes; and the
|
||||||
* Users-page route moves a saved switch-user profile in — the one this
|
* Users-page route moves a saved switch-user profile in — the one this
|
||||||
* machine's own Claude Code is logged into with its LIVE login, the host's
|
* machine's own Claude Code is logged into with its LIVE login, the host's
|
||||||
* home then being one more placement the server refreshes.
|
* home then being one more placement the server refreshes. A pin of the
|
||||||
|
* owner to another login waits while a live pane holds the first, until the
|
||||||
|
* admin's switch writes it into the home (never over the member's own login).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -188,6 +190,51 @@ describe("a pool login", () => {
|
|||||||
assert.equal(pool.__read("team"), null);
|
assert.equal(pool.__read("team"), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("follows the owner's pin, and an admin's switch moves the home's login now", async () => {
|
||||||
|
const { db } = require("../db");
|
||||||
|
const pins = require("../lib/account-pins");
|
||||||
|
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
|
||||||
|
pool.addLoginAccount("alpha", { oauth: oauth("a"), account: ACCOUNT });
|
||||||
|
pool.addLoginAccount("beta", {
|
||||||
|
oauth: oauth("b"),
|
||||||
|
account: { accountUuid: "uuid-beta", emailAddress: "beta@example.com" },
|
||||||
|
});
|
||||||
|
assert.equal(pool.forSpawn(acct, miaId).name, "alpha", "unpinned: the balancer's pick");
|
||||||
|
pins.setPin(miaId, "claude", "beta");
|
||||||
|
assert.equal(pool.forSpawn(acct, miaId).name, "beta");
|
||||||
|
assert.equal(pool.forSpawn(acct, null).name, "alpha", "another owner is not pinned");
|
||||||
|
// A pane of mia's runs on alpha: beta waits until it ends.
|
||||||
|
pool.forSpawn(acct).place();
|
||||||
|
fs.mkdirSync(path.join(HOME, "proj"), { recursive: true });
|
||||||
|
const lane = db
|
||||||
|
.prepare("INSERT INTO lanes (cwd, owner_id) VALUES (?, ?)")
|
||||||
|
.run(path.join(HOME, "proj"), miaId).lastInsertRowid;
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO dashboard_runs (id, mode, cwd, status, started_at, account, lane_id, tool)
|
||||||
|
VALUES ('pin-run', 'interactive', ?, 'running', ?, 'alpha', ?, 'claude')`
|
||||||
|
).run(path.join(HOME, "proj"), new Date().toISOString(), lane);
|
||||||
|
assert.equal(pool.forSpawn(acct, miaId).name, "alpha", "a bare pin waits for the pane");
|
||||||
|
// The admin's switch writes beta into the home now; the pane is not touched.
|
||||||
|
assert.equal(await pins.applyPin(miaId, "claude"), "switched");
|
||||||
|
assert.equal(diskCreds().refreshToken, oauth("b").refreshToken);
|
||||||
|
assert.equal(pool.__read("alpha").placed.mia, undefined, "alpha no longer holds mia's home");
|
||||||
|
assert.equal(pool.forSpawn(acct, miaId).name, "beta");
|
||||||
|
assert.equal(await pins.applyPin(miaId, "claude"), "kept");
|
||||||
|
// Unpinned with the pane still live: the home's login stays, no flip back.
|
||||||
|
pins.setPin(miaId, "claude", null);
|
||||||
|
assert.equal(pool.forSpawn(acct, miaId).name, "beta");
|
||||||
|
// A login the member made themselves is never written over.
|
||||||
|
fs.writeFileSync(CREDS, JSON.stringify({ claudeAiOauth: oauth("own") }));
|
||||||
|
pins.setPin(miaId, "claude", "alpha");
|
||||||
|
assert.equal(await pins.applyPin(miaId, "claude"), "own-login");
|
||||||
|
assert.equal(diskCreds().refreshToken, oauth("own").refreshToken);
|
||||||
|
pins.setPin(miaId, "claude", "beta");
|
||||||
|
pool.removeAccount("beta");
|
||||||
|
assert.equal(pins.pinFor(miaId, "claude"), null, "a removed account drops its pins");
|
||||||
|
db.prepare("DELETE FROM dashboard_runs WHERE id = 'pin-run'").run();
|
||||||
|
db.prepare("DELETE FROM lanes WHERE id = ?").run(lane);
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses a second copy of one Claude account", () => {
|
it("refuses a second copy of one Claude account", () => {
|
||||||
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
|
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
|
||||||
assert.throws(() => pool.addLoginAccount("team-2", { oauth: oauth("b"), account: ACCOUNT }), {
|
assert.throws(() => pool.addLoginAccount("team-2", { oauth: oauth("b"), account: ACCOUNT }), {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* @file runtime-deps.test.js
|
||||||
|
* @description Every package the server code requires is a runtime dependency.
|
||||||
|
* A plugin install runs `npm install --omit=dev`, so a package listed only in
|
||||||
|
* devDependencies — or one that arrived only as some other package's
|
||||||
|
* dependency — is missing there. v2.8.9 crashed at start on every plugin
|
||||||
|
* install: `js-yaml` (server/lib/lane-detect.js) was a devDependency that
|
||||||
|
* `redoc` used to pull in, and dropping `redoc` took it away. Machines with
|
||||||
|
* Debian's global /usr/share/nodejs never saw it, so this checks the manifest
|
||||||
|
* instead of trusting a local `require` to fail.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { builtinModules } = require("node:module");
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, "..", "..");
|
||||||
|
const pkg = require(path.join(ROOT, "package.json"));
|
||||||
|
|
||||||
|
// Required only behind a runtime check, never on a plain Node server.
|
||||||
|
const GUARDED = new Set(["electron"]);
|
||||||
|
|
||||||
|
function sourceFiles(dir) {
|
||||||
|
const out = [];
|
||||||
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const p = path.join(dir, e.name);
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
if (!["node_modules", "__tests__", "vendor"].includes(e.name)) out.push(...sourceFiles(p));
|
||||||
|
} else if (/\.(c?js|mjs)$/.test(e.name)) out.push(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bare package names `file` requires, comments left out. */
|
||||||
|
function requiredPackages(file) {
|
||||||
|
const code = fs
|
||||||
|
.readFileSync(file, "utf8")
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||||
|
.replace(/(^|[^:"'`\\])\/\/.*$/gm, "$1");
|
||||||
|
const names = new Set();
|
||||||
|
for (const m of code.matchAll(/\brequire\(\s*["']([^"'./][^"']*)["']\s*\)/g)) {
|
||||||
|
const spec = m[1];
|
||||||
|
if (spec.startsWith("node:")) continue;
|
||||||
|
const name = spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0];
|
||||||
|
if (!builtinModules.includes(name)) names.add(name);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("runtime dependencies", () => {
|
||||||
|
it("every package server/ requires is in dependencies or optionalDependencies", () => {
|
||||||
|
const runtime = new Set([
|
||||||
|
...Object.keys(pkg.dependencies || {}),
|
||||||
|
...Object.keys(pkg.optionalDependencies || {}),
|
||||||
|
]);
|
||||||
|
const missing = [];
|
||||||
|
for (const file of sourceFiles(path.join(ROOT, "server"))) {
|
||||||
|
for (const name of requiredPackages(file)) {
|
||||||
|
if (!runtime.has(name) && !GUARDED.has(name))
|
||||||
|
missing.push(`${path.relative(ROOT, file)}: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual(missing, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
* admin may add or remove an account, each change needs the admin's OWN
|
* admin may add or remove an account, each change needs the admin's OWN
|
||||||
* password again (EREAUTH), the token only goes in — no answer (the add, the
|
* password again (EREAUTH), the token only goes in — no answer (the add, the
|
||||||
* list) ever carries it — the audit log names the account, not its secret, and
|
* list) ever carries it — the audit log names the account, not its secret, and
|
||||||
* in local mode the routes refuse.
|
* in local mode the routes refuse. An admin pins a user to one account
|
||||||
|
* (`PUT /api/accounts/pins`): that user's new panes skip the balancer.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -144,3 +145,112 @@ describe("the account pool from the Users page", () => {
|
|||||||
assert.ok(claudePool.pickAccount(), "team-1 is still there");
|
assert.ok(claudePool.pickAccount(), "team-1 is still there");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("pinning a user to one account", () => {
|
||||||
|
const pins = require("../lib/account-pins");
|
||||||
|
const pin = (cookie, body) =>
|
||||||
|
call("PUT", "/api/accounts/pins", {
|
||||||
|
cookie,
|
||||||
|
body: { tool: "claude", currentPassword: BOSS_PASS, ...body },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an admin pin and unpin, audited, and new panes follow the pin", async () => {
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
|
||||||
|
claudePool.addAccount("team-z", `sk-ant-oat01-${"z".repeat(40)}`);
|
||||||
|
assert.equal(claudePool.pickAccount(null, miaId).name, "team-1", "the balancer's pick");
|
||||||
|
const r = await pin(boss, { userId: miaId, account: "team-z" });
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.deepEqual(r.body.pin, { userId: miaId, username: "mia", account: "team-z" });
|
||||||
|
assert.equal(r.body.switched, "next-session", "a setup-token goes in at the next spawn");
|
||||||
|
assert.equal(claudePool.pickAccount(null, miaId).name, "team-z");
|
||||||
|
assert.equal(claudePool.pickAccount(null, null).name, "team-1", "nobody else moves");
|
||||||
|
const audit = db.prepare("SELECT owner_id, path FROM audit_log ORDER BY id DESC LIMIT 1").get();
|
||||||
|
assert.equal(audit.owner_id, miaId);
|
||||||
|
assert.match(audit.path, /pin mia to claude account team-z/);
|
||||||
|
const list = await call("GET", "/api/accounts", { cookie: boss });
|
||||||
|
assert.deepEqual(
|
||||||
|
list.body.pins.claude.map((p) => [p.username, p.account]),
|
||||||
|
[["mia", "team-z"]]
|
||||||
|
);
|
||||||
|
assert.deepEqual(list.body.pins.codex, []);
|
||||||
|
const off = await pin(boss, { userId: miaId, account: null });
|
||||||
|
assert.equal(off.body.pin.account, null);
|
||||||
|
assert.equal(claudePool.pickAccount(null, miaId).name, "team-1");
|
||||||
|
claudePool.removeAccount("team-z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an unknown account or user, a wrong password, and a non-admin", async () => {
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
|
||||||
|
assert.equal(
|
||||||
|
(await pin(boss, { userId: miaId, account: "nope" })).body.error.code,
|
||||||
|
"ENOACCOUNT"
|
||||||
|
);
|
||||||
|
assert.equal((await pin(boss, { userId: 9999, account: "team-1" })).body.error.code, "ENOUSER");
|
||||||
|
assert.equal(
|
||||||
|
(await pin(boss, { userId: miaId, account: "team-1", currentPassword: "x" })).body.error.code,
|
||||||
|
"EREAUTH"
|
||||||
|
);
|
||||||
|
const ana = await login("ana", "analyst-pass-1");
|
||||||
|
const w = await call("PUT", "/api/accounts/pins", {
|
||||||
|
cookie: ana,
|
||||||
|
body: { tool: "claude", userId: miaId, account: "team-1", currentPassword: "analyst-pass-1" },
|
||||||
|
});
|
||||||
|
assert.equal(w.status, 403);
|
||||||
|
assert.equal(pins.pinFor(miaId, "claude"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets exactly who runs on one account from its Edit", async () => {
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
const id = (name) => db.prepare("SELECT id FROM users WHERE username = ?").get(name).id;
|
||||||
|
claudePool.addAccount("team-y", `sk-ant-oat01-${"y".repeat(40)}`);
|
||||||
|
pins.setPin(id("ana"), "claude", "team-1");
|
||||||
|
const edit = (account, userIds, extra = {}) =>
|
||||||
|
call("PUT", `/api/accounts/${account}/users`, {
|
||||||
|
cookie: boss,
|
||||||
|
body: { tool: "claude", userIds, currentPassword: BOSS_PASS, ...extra },
|
||||||
|
});
|
||||||
|
const r = await edit("team-y", [id("mia"), id("ana")]);
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.deepEqual(r.body.added.map((u) => [u.username, u.switched]).sort(), [
|
||||||
|
["ana", "next-session"],
|
||||||
|
["mia", "next-session"],
|
||||||
|
]);
|
||||||
|
assert.equal(pins.pinFor(id("ana"), "claude"), "team-y", "moved off team-1");
|
||||||
|
const again = await edit("team-y", [id("mia")]);
|
||||||
|
assert.deepEqual(again.body.added, []);
|
||||||
|
assert.deepEqual(
|
||||||
|
again.body.removed.map((u) => u.username),
|
||||||
|
["ana"]
|
||||||
|
);
|
||||||
|
assert.equal(pins.pinFor(id("ana"), "claude"), null);
|
||||||
|
assert.equal(pins.pinFor(id("mia"), "claude"), "team-y");
|
||||||
|
const audit = db.prepare("SELECT path FROM audit_log ORDER BY id DESC LIMIT 1").get();
|
||||||
|
assert.match(audit.path, /unpin ana from claude/);
|
||||||
|
assert.equal((await edit("team-y", [9999])).body.error.code, "ENOUSER");
|
||||||
|
assert.equal((await edit("team-y", "x")).body.error.code, "EBADUSER");
|
||||||
|
assert.equal((await edit("nope", [])).body.error.code, "ENOACCOUNT");
|
||||||
|
assert.equal((await edit("team-y", [], { currentPassword: "x" })).body.error.code, "EREAUTH");
|
||||||
|
assert.equal(pins.pinFor(id("mia"), "claude"), "team-y", "a refused edit changes nothing");
|
||||||
|
await edit("team-y", []);
|
||||||
|
claudePool.removeAccount("team-y");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is ignored in local mode and dropped with its user", async () => {
|
||||||
|
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
|
||||||
|
pins.setPin(miaId, "claude", "team-1");
|
||||||
|
delete process.env.AGENTHUB_MODE;
|
||||||
|
try {
|
||||||
|
assert.equal(pins.pinFor(miaId, "claude"), null);
|
||||||
|
} finally {
|
||||||
|
process.env.AGENTHUB_MODE = "server";
|
||||||
|
}
|
||||||
|
assert.equal(pins.pinFor(miaId, "claude"), "team-1");
|
||||||
|
db.prepare("DELETE FROM users WHERE id = ?").run(miaId);
|
||||||
|
assert.equal(
|
||||||
|
db.prepare("SELECT COUNT(*) AS n FROM account_pins WHERE user_id = ?").get(miaId).n,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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(`
|
||||||
@@ -1873,6 +1908,18 @@ for (const col of ["display_name", "email", "avatar_at"]) {
|
|||||||
db.prepare(`ALTER TABLE users ADD COLUMN ${col} TEXT`).run();
|
db.prepare(`ALTER TABLE users ADD COLUMN ${col} TEXT`).run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// An admin's pin of one server-mode user to one pool account per tool
|
||||||
|
// (server/lib/account-pins.js): that user's new sessions skip the balancer.
|
||||||
|
// The account is named, not referenced — the pool lives in files.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS account_pins (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
tool TEXT NOT NULL CHECK(tool IN ('claude','codex')),
|
||||||
|
account TEXT NOT NULL,
|
||||||
|
pinned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
PRIMARY KEY (user_id, tool)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
// An existing database made before auto_vacuum: the setting is stored only by
|
// An existing database made before auto_vacuum: the setting is stored only by
|
||||||
// a VACUUM, which rewrites the whole file under an exclusive lock. Done here,
|
// a VACUUM, which rewrites the whole file under an exclusive lock. Done here,
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* @file account-pins.js
|
||||||
|
* @description Account pins: an admin's choice that one server-mode user's
|
||||||
|
* new sessions of a tool run on one named pool account instead of the one the
|
||||||
|
* balancer would pick (claude-pool.js / codex-pool.js `pick`) — the Users
|
||||||
|
* page's "switch a user's account". One pin per user and tool
|
||||||
|
* (`account_pins`); the user is the workspace's OWNER
|
||||||
|
* (`lanes.owner_id`), never whoever clicked Start. A pin is a preference the
|
||||||
|
* pool still bounds: an account a pane may not use (expired, or a login the
|
||||||
|
* pane's home cannot take) falls back to the balancer. A Claude login is
|
||||||
|
* written into the user's home right away (`applyPin`), over the pool login
|
||||||
|
* their running panes use, so those move to it without a restart.
|
||||||
|
*
|
||||||
|
* `setAccountUsers` is the Users page's Edit of one account: who runs on it.
|
||||||
|
*
|
||||||
|
* Server mode only: in local mode there are no users and nothing is pinned.
|
||||||
|
* Removing the account drops its pins (`dropAccount`); deleting the user drops
|
||||||
|
* theirs (ON DELETE CASCADE, and `auth.deleteUser`).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TOOLS = ["claude", "codex"];
|
||||||
|
|
||||||
|
const err = (code, message) => Object.assign(new Error(message), { code });
|
||||||
|
const db = () => require("../db").db;
|
||||||
|
const poolOf = (tool) => require(tool === "codex" ? "./codex-pool" : "./claude-pool");
|
||||||
|
|
||||||
|
function validTool(tool) {
|
||||||
|
if (!TOOLS.includes(tool)) throw err("EBADTOOL", "tool: claude or codex");
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The account name `ownerId`'s new `tool` sessions are pinned to, or null. */
|
||||||
|
function pinFor(ownerId, tool) {
|
||||||
|
if (!require("./auth").isServerMode() || !Number.isInteger(ownerId)) return null;
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
db()
|
||||||
|
.prepare("SELECT account FROM account_pins WHERE user_id = ? AND tool = ?")
|
||||||
|
.get(ownerId, tool)?.account ?? null
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null; // a spawn never fails over a pin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every pin of `tool`, with its user's name: `[{userId, username, account, pinnedAt}]`. */
|
||||||
|
function listPins(tool) {
|
||||||
|
return db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT p.user_id AS userId, u.username, p.account, p.pinned_at AS pinnedAt
|
||||||
|
FROM account_pins p JOIN users u ON u.id = p.user_id
|
||||||
|
WHERE p.tool = ? ORDER BY u.username`
|
||||||
|
)
|
||||||
|
.all(validTool(tool));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin `userId`'s new `tool` sessions to `account`, or unpin them (`account`
|
||||||
|
* null). Returns `{userId, username, account}`.
|
||||||
|
*/
|
||||||
|
function setPin(userId, tool, account) {
|
||||||
|
validTool(tool);
|
||||||
|
const user = Number.isInteger(userId)
|
||||||
|
? db().prepare("SELECT id, username FROM users WHERE id = ?").get(userId)
|
||||||
|
: null;
|
||||||
|
if (!user) throw err("ENOUSER", `no user ${userId}`);
|
||||||
|
if (account == null || account === "") {
|
||||||
|
db().prepare("DELETE FROM account_pins WHERE user_id = ? AND tool = ?").run(user.id, tool);
|
||||||
|
return { userId: user.id, username: user.username, account: null };
|
||||||
|
}
|
||||||
|
const name = String(account);
|
||||||
|
if (!poolOf(tool).hasAccount(name)) {
|
||||||
|
throw err("ENOACCOUNT", `no ${tool} account ${name} in the pool`);
|
||||||
|
}
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO account_pins (user_id, tool, account) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, tool) DO UPDATE SET account = excluded.account,
|
||||||
|
pinned_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
|
||||||
|
)
|
||||||
|
.run(user.id, tool, name);
|
||||||
|
return { userId: user.id, username: user.username, account: name };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Users page's Edit of one account: exactly `userIds` run on `account`
|
||||||
|
* afterwards. A user pinned to another account of the tool moves here; one
|
||||||
|
* pinned here but left out goes back to the balancer. One transaction.
|
||||||
|
* Returns `{added, removed}` (each `[{userId, username}]`).
|
||||||
|
*/
|
||||||
|
function setAccountUsers(tool, account, userIds) {
|
||||||
|
validTool(tool);
|
||||||
|
const name = String(account ?? "");
|
||||||
|
if (!poolOf(tool).hasAccount(name)) {
|
||||||
|
throw err("ENOACCOUNT", `no ${tool} account ${name} in the pool`);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(userIds) || !userIds.every(Number.isInteger)) {
|
||||||
|
throw err("EBADUSER", "userIds: an array of user ids");
|
||||||
|
}
|
||||||
|
const want = [...new Set(userIds)];
|
||||||
|
const users = want.map((id) => {
|
||||||
|
const u = db().prepare("SELECT id, username FROM users WHERE id = ?").get(id);
|
||||||
|
if (!u) throw err("ENOUSER", `no user ${id}`);
|
||||||
|
return u;
|
||||||
|
});
|
||||||
|
const before = listPins(tool);
|
||||||
|
const here = new Set(before.filter((p) => p.account === name).map((p) => p.userId));
|
||||||
|
const added = users.filter((u) => !here.has(u.id));
|
||||||
|
const removed = before.filter((p) => p.account === name && !want.includes(p.userId));
|
||||||
|
db().transaction(() => {
|
||||||
|
for (const u of added) setPin(u.id, tool, name);
|
||||||
|
for (const p of removed) setPin(p.userId, tool, null);
|
||||||
|
})();
|
||||||
|
return {
|
||||||
|
added: added.map((u) => ({ userId: u.id, username: u.username })),
|
||||||
|
removed: removed.map((p) => ({ userId: p.userId, username: p.username })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a pin take effect now where it can: a Claude login is written into
|
||||||
|
* the user's home at once (claude-pool `switchHome`), so their running panes
|
||||||
|
* move to it without a restart. Anything else goes in at the next spawn.
|
||||||
|
* Answers what happened (see `switchHome`).
|
||||||
|
*/
|
||||||
|
async function applyPin(userId, tool) {
|
||||||
|
const pin = pinFor(userId, tool);
|
||||||
|
if (!pin || tool !== "claude") return "next-session";
|
||||||
|
const user = db().prepare("SELECT username FROM users WHERE id = ?").get(userId);
|
||||||
|
const member = user ? require("./run-as").accountForUser(user.username) : null;
|
||||||
|
return poolOf(tool).switchHome(member, pin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A removed account takes its pins with it. */
|
||||||
|
function dropAccount(tool, account) {
|
||||||
|
try {
|
||||||
|
db().prepare("DELETE FROM account_pins WHERE tool = ? AND account = ?").run(tool, account);
|
||||||
|
} catch {
|
||||||
|
/* no database (a bare CLI test): nothing pinned */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { TOOLS, pinFor, listPins, setPin, setAccountUsers, applyPin, dropAccount };
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -367,6 +367,7 @@ function deleteUser(username) {
|
|||||||
} catch {
|
} catch {
|
||||||
/* no personal profiles */
|
/* no personal profiles */
|
||||||
}
|
}
|
||||||
|
sql("DELETE FROM account_pins WHERE user_id = ?").run(u.id);
|
||||||
sql("DELETE FROM users WHERE id = ?").run(u.id);
|
sql("DELETE FROM users WHERE id = ?").run(u.id);
|
||||||
return { id: u.id, username: u.username, workspaces: lanes.length };
|
return { id: u.id, username: u.username, workspaces: lanes.length };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@
|
|||||||
* 3. least $ spent in the last 5 hours, then in the last 7 days — the
|
* 3. least $ spent in the last 5 hours, then in the last 7 days — the
|
||||||
* sessions its runs (`dashboard_runs.account`) held, over the hours they
|
* sessions its runs (`dashboard_runs.account`) held, over the hours they
|
||||||
* held them (pool-usage.js).
|
* held them (pool-usage.js).
|
||||||
|
* An admin may switch a user to one account (account-pins.js): that user's
|
||||||
|
* panes skip the ranking whenever they may use it, and a login is written
|
||||||
|
* into their home at once (`switchHome`) — running panes take it at their
|
||||||
|
* next credentials read, without a restart.
|
||||||
* The quota is read with the account's own token in the background, at most
|
* The quota is read with the account's own token in the background, at most
|
||||||
* every `QUOTA_TTL_MS`; a token the usage endpoint refuses simply has no
|
* every `QUOTA_TTL_MS`; a token the usage endpoint refuses simply has no
|
||||||
* reading, and ranking falls back to spend.
|
* reading, and ranking falls back to spend.
|
||||||
@@ -193,6 +197,7 @@ function removeAccount(name) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fs.rmSync(fileOf(n));
|
fs.rmSync(fileOf(n));
|
||||||
|
require("./account-pins").dropAccount("claude", n);
|
||||||
quotaCache.delete(n);
|
quotaCache.delete(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +337,40 @@ async function refreshLogins() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An admin's switch (account-pins.js): put pool login `name` into `member`'s
|
||||||
|
* home NOW, over the pool login it holds — never over a login the member made
|
||||||
|
* themselves. Every home's newer pair is copied back first (refreshLogins),
|
||||||
|
* so the login leaving the home keeps its only live refresh token. Running
|
||||||
|
* panes are not restarted: Claude Code takes the new pair when it next
|
||||||
|
* re-reads its credentials (before a refresh), new panes at once. Answers
|
||||||
|
* `switched`, `kept` (already there), `own-login` (left alone), `expired`, or
|
||||||
|
* `next-session` (a setup-token, or no member home: it goes in at spawn).
|
||||||
|
*/
|
||||||
|
async function switchHome(member, name) {
|
||||||
|
const first = readAccount(validName(name));
|
||||||
|
if (!first) throw err("ENOACCOUNT", `no account ${name} in the pool`);
|
||||||
|
if (first.kind !== "login" || !member || member.host) return "next-session";
|
||||||
|
await refreshLogins();
|
||||||
|
const a = readAccount(first.name);
|
||||||
|
if (!a || expired(a)) return "expired";
|
||||||
|
const disk = homeCreds(member);
|
||||||
|
if (disk && ownsHome(a, member.name, disk)) return "kept";
|
||||||
|
const holder = disk && allAccounts().find((x) => ownsHome(x, member.name, disk));
|
||||||
|
if (disk && !holder) return "own-login";
|
||||||
|
if (holder) {
|
||||||
|
const placed = { ...holder.placed };
|
||||||
|
delete placed[member.name];
|
||||||
|
save({ ...holder, placed });
|
||||||
|
}
|
||||||
|
save(writeLogin(a, member));
|
||||||
|
runAs().markClaudeOnboarded(member, {
|
||||||
|
...(a.account ? { oauthAccount: a.account } : {}),
|
||||||
|
...(a.userID ? { userID: a.userID } : {}),
|
||||||
|
});
|
||||||
|
return "switched";
|
||||||
|
}
|
||||||
|
|
||||||
// ── Quota, read in the background ──────────────────────────────────────────
|
// ── Quota, read in the background ──────────────────────────────────────────
|
||||||
|
|
||||||
/** name → { at, fiveHour?, sevenDay?, error? } */
|
/** name → { at, fiveHour?, sevenDay?, error? } */
|
||||||
@@ -434,10 +473,15 @@ function liveInHome(name, member) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The full record a new Claude pane should run on, or null. `member` = run-as account. */
|
/**
|
||||||
function pick(member) {
|
* The full record a new Claude pane should run on, or null. `member` = run-as
|
||||||
|
* account; `ownerId` = the workspace owner, whose pin (account-pins.js) wins
|
||||||
|
* over the ranking when this pane may use that account.
|
||||||
|
*/
|
||||||
|
function pick(member, ownerId = null) {
|
||||||
let usable = allAccounts().filter((a) => !expired(a));
|
let usable = allAccounts().filter((a) => !expired(a));
|
||||||
if (usable.length === 0) return null;
|
if (usable.length === 0) return null;
|
||||||
|
let live = null;
|
||||||
if (!member) {
|
if (!member) {
|
||||||
// A host pane's home holds the host's own login — a pool account only when
|
// A host pane's home holds the host's own login — a pool account only when
|
||||||
// it was moved in from there (`hostHolds`) and is still the pair on disk:
|
// it was moved in from there (`hostHolds`) and is still the pair on disk:
|
||||||
@@ -453,10 +497,18 @@ function pick(member) {
|
|||||||
} else {
|
} else {
|
||||||
// One .credentials.json per home: while a pane of theirs runs on a
|
// One .credentials.json per home: while a pane of theirs runs on a
|
||||||
// pool login, the next pane stays on that one.
|
// pool login, the next pane stays on that one.
|
||||||
const live = usable.find((a) => a.kind === "login" && liveInHome(a.name, member));
|
// The login in the home is the one they run on — after an admin's
|
||||||
if (live) return live;
|
// switch (switchHome) it is no longer the one those panes started on.
|
||||||
|
const liveLogin = usable.find((a) => a.kind === "login" && liveInHome(a.name, member));
|
||||||
|
live = liveLogin ? placed || liveLogin : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A pinned token goes in by env and wins outright; a pinned login waits
|
||||||
|
// while another login holds this home.
|
||||||
|
const pin = require("./account-pins").pinFor(ownerId, "claude");
|
||||||
|
const pinned = pin ? usable.find((a) => a.name === pin) : null;
|
||||||
|
if (pinned && (pinned.kind === "token" || !live || live === pinned)) return pinned;
|
||||||
|
if (live) return live;
|
||||||
if (usable.length === 0) return null;
|
if (usable.length === 0) return null;
|
||||||
refreshQuotas().catch(() => {});
|
refreshQuotas().catch(() => {});
|
||||||
const ranked = usable
|
const ranked = usable
|
||||||
@@ -489,8 +541,8 @@ function pick(member) {
|
|||||||
* background re-read. A `login` is offered for a member, and for a host pane
|
* background re-read. A `login` is offered for a member, and for a host pane
|
||||||
* only when it is the host's own login (`hostHolds`), which stays as it is.
|
* only when it is the host's own login (`hostHolds`), which stays as it is.
|
||||||
*/
|
*/
|
||||||
function pickAccount(member = null) {
|
function pickAccount(member = null, ownerId = null) {
|
||||||
const a = pick(member);
|
const a = pick(member, ownerId);
|
||||||
if (!a) return null;
|
if (!a) return null;
|
||||||
return a.kind === "login"
|
return a.kind === "login"
|
||||||
? { name: a.name, kind: "login" }
|
? { name: a.name, kind: "login" }
|
||||||
@@ -502,8 +554,8 @@ function pickAccount(member = null) {
|
|||||||
* `env` goes through tmux `-e` (a member's launch file); `place()` runs just
|
* `env` goes through tmux `-e` (a member's launch file); `place()` runs just
|
||||||
* before the pane starts and writes a login into the member's home.
|
* before the pane starts and writes a login into the member's home.
|
||||||
*/
|
*/
|
||||||
function forSpawn(member = null) {
|
function forSpawn(member = null, ownerId = null) {
|
||||||
const a = pick(member);
|
const a = pick(member, ownerId);
|
||||||
if (!a) return null;
|
if (!a) return null;
|
||||||
if (a.kind === "token") {
|
if (a.kind === "token") {
|
||||||
return { name: a.name, env: { CLAUDE_CODE_OAUTH_TOKEN: a.token }, place: () => {} };
|
return { name: a.name, env: { CLAUDE_CODE_OAUTH_TOKEN: a.token }, place: () => {} };
|
||||||
@@ -524,8 +576,12 @@ function forSpawn(member = null) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Is `name` an account in the pool (any state)? */
|
||||||
|
const hasAccount = (name) => NAME_RE.test(String(name)) && Boolean(readAccount(String(name)));
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
BLOCK_PERCENT,
|
BLOCK_PERCENT,
|
||||||
|
hasAccount,
|
||||||
REFRESH_AHEAD_MS,
|
REFRESH_AHEAD_MS,
|
||||||
addAccount,
|
addAccount,
|
||||||
addLoginAccount,
|
addLoginAccount,
|
||||||
@@ -533,6 +589,7 @@ module.exports = {
|
|||||||
poolStatus,
|
poolStatus,
|
||||||
pickAccount,
|
pickAccount,
|
||||||
forSpawn,
|
forSpawn,
|
||||||
|
switchHome,
|
||||||
refreshQuotas,
|
refreshQuotas,
|
||||||
refreshLogins,
|
refreshLogins,
|
||||||
__read: readAccount,
|
__read: readAccount,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
* the last 5 h, then 7 d. The reading is the newest `rate_limits` in the
|
* the last 5 h, then 7 d. The reading is the newest `rate_limits` in the
|
||||||
* rollouts of the sessions that account's runs started (no network), each
|
* rollouts of the sessions that account's runs started (no network), each
|
||||||
* from the CODEX_HOME of the account its workspace runs as.
|
* from the CODEX_HOME of the account its workspace runs as.
|
||||||
|
* A user an admin pinned to one account (account-pins.js) skips the ranking.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -139,6 +140,7 @@ function removeAccount(name) {
|
|||||||
const n = validName(name);
|
const n = validName(name);
|
||||||
if (!readAccount(n)) throw err("ENOACCOUNT", `no account ${n} in the pool`);
|
if (!readAccount(n)) throw err("ENOACCOUNT", `no account ${n} in the pool`);
|
||||||
fs.rmSync(fileOf(n));
|
fs.rmSync(fileOf(n));
|
||||||
|
require("./account-pins").dropAccount("codex", n);
|
||||||
quotaCache = null;
|
quotaCache = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,10 +334,15 @@ function syncBack() {
|
|||||||
|
|
||||||
// ── Picking ────────────────────────────────────────────────────────────────
|
// ── Picking ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** The full record a new Codex pane should run on, or null. `member` = run-as account. */
|
/**
|
||||||
function pick(member) {
|
* The full record a new Codex pane should run on, or null. `member` = run-as
|
||||||
|
* account; `ownerId` = the workspace owner, whose pin (account-pins.js) wins
|
||||||
|
* over the ranking when this pane may use that account.
|
||||||
|
*/
|
||||||
|
function pick(member, ownerId = null) {
|
||||||
let usable = allAccounts();
|
let usable = allAccounts();
|
||||||
if (usable.length === 0) return null;
|
if (usable.length === 0) return null;
|
||||||
|
let live = null;
|
||||||
if (!member) {
|
if (!member) {
|
||||||
// A host pane's CODEX_HOME holds the host's own login.
|
// A host pane's CODEX_HOME holds the host's own login.
|
||||||
usable = usable.filter((a) => a.kind === "apikey");
|
usable = usable.filter((a) => a.kind === "apikey");
|
||||||
@@ -348,10 +355,15 @@ function pick(member) {
|
|||||||
} else {
|
} else {
|
||||||
// One auth.json per home: while a pane of theirs runs on a pool
|
// One auth.json per home: while a pane of theirs runs on a pool
|
||||||
// login, the next pane stays on that one.
|
// login, the next pane stays on that one.
|
||||||
const live = usable.find((a) => a.kind === "chatgpt" && liveInHome(a.name, member));
|
live = usable.find((a) => a.kind === "chatgpt" && liveInHome(a.name, member)) || null;
|
||||||
if (live) return live;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A pinned API key goes in by env and wins outright; a pinned ChatGPT login
|
||||||
|
// waits while another login holds this home.
|
||||||
|
const pin = require("./account-pins").pinFor(ownerId, "codex");
|
||||||
|
const pinned = pin ? usable.find((a) => a.name === pin) : null;
|
||||||
|
if (pinned && (pinned.kind === "apikey" || !live || live === pinned)) return pinned;
|
||||||
|
if (live) return live;
|
||||||
if (usable.length === 0) return null;
|
if (usable.length === 0) return null;
|
||||||
const ranked = usable
|
const ranked = usable
|
||||||
.map((a) => {
|
.map((a) => {
|
||||||
@@ -395,8 +407,8 @@ function liveInHome(name, member) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** `{name, kind}` of the account a new Codex pane would get, or null. */
|
/** `{name, kind}` of the account a new Codex pane would get, or null. */
|
||||||
function pickAccount(member = null) {
|
function pickAccount(member = null, ownerId = null) {
|
||||||
const a = pick(member);
|
const a = pick(member, ownerId);
|
||||||
return a ? { name: a.name, kind: a.kind } : null;
|
return a ? { name: a.name, kind: a.kind } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,8 +417,8 @@ function pickAccount(member = null) {
|
|||||||
* this pane may use): `{name, argv, env, place}`. `env` goes through tmux `-e`
|
* this pane may use): `{name, argv, env, place}`. `env` goes through tmux `-e`
|
||||||
* (a member's launch file); `place()` runs just before the pane starts.
|
* (a member's launch file); `place()` runs just before the pane starts.
|
||||||
*/
|
*/
|
||||||
function forSpawn(member = null) {
|
function forSpawn(member = null, ownerId = null) {
|
||||||
const a = pick(member);
|
const a = pick(member, ownerId);
|
||||||
if (!a) return null;
|
if (!a) return null;
|
||||||
if (a.kind === "apikey") {
|
if (a.kind === "apikey") {
|
||||||
const provider = `{name="OpenAI (AgentHub pool)",base_url="https://api.openai.com/v1",env_key="${KEY_ENV}",wire_api="responses"}`;
|
const provider = `{name="OpenAI (AgentHub pool)",base_url="https://api.openai.com/v1",env_key="${KEY_ENV}",wire_api="responses"}`;
|
||||||
@@ -439,8 +451,12 @@ function forSpawn(member = null) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Is `name` an account in the pool? */
|
||||||
|
const hasAccount = (name) => NAME_RE.test(String(name)) && Boolean(readAccount(String(name)));
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
BLOCK_PERCENT,
|
BLOCK_PERCENT,
|
||||||
|
hasAccount,
|
||||||
addAccount,
|
addAccount,
|
||||||
removeAccount,
|
removeAccount,
|
||||||
poolStatus,
|
poolStatus,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user