Compare commits
11 Commits
v2.8.8
..
f8d07dab61
| Author | SHA1 | Date | |
|---|---|---|---|
| f8d07dab61 | |||
| 0434ffc750 | |||
| e2d8d314f6 | |||
| 0c8782ea75 | |||
| c042adc51b | |||
| 776ad6466f | |||
| 9c98638ec0 | |||
| aa8ac6cad9 | |||
| 3237ce4774 | |||
| 7c5f30df7d | |||
| 5e0a9b526b |
+87
-6
@@ -2855,8 +2855,8 @@ const COMMAND_GROUPS = [
|
||||
],
|
||||
[
|
||||
"accounts",
|
||||
"list|add|remove [name] [--tool codex]",
|
||||
"Claude/Codex account pool: new sessions spread over these accounts",
|
||||
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex]",
|
||||
"Claude/Codex account pool: new sessions spread over these accounts; pin = one user always on one",
|
||||
],
|
||||
["repl", "", "Open the interactive shell (also: shell, i)"],
|
||||
],
|
||||
@@ -3814,7 +3814,7 @@ const SUBCOMMANDS = {
|
||||
service: ["install", "uninstall", "status"],
|
||||
server: ["setup", "off", "status"],
|
||||
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
|
||||
accounts: ["list", "add", "remove"],
|
||||
accounts: ["list", "add", "remove", "pin", "unpin"],
|
||||
pricing: ["set", "delete", "reset"],
|
||||
webhooks: ["test"],
|
||||
import: ["rescan", "path"],
|
||||
@@ -4286,7 +4286,18 @@ async function cmdCodexAccounts(sub, name, flags) {
|
||||
}
|
||||
pool.resetQuota();
|
||||
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
|
||||
.poolStatus()
|
||||
.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.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). */
|
||||
async function cmdAccounts(sub, positional, flags = {}) {
|
||||
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") {
|
||||
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 (sub === "add") {
|
||||
console.log(
|
||||
@@ -4337,7 +4404,18 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
await pool.refreshQuotas({ force: true });
|
||||
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
|
||||
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
|
||||
.poolStatus()
|
||||
.map((a) => [
|
||||
@@ -4356,6 +4434,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
: a.quota?.error
|
||||
? `no quota (${a.quota.error})`
|
||||
: "ok",
|
||||
pinnedUsers("claude", a.name),
|
||||
])
|
||||
);
|
||||
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
||||
@@ -4363,7 +4442,9 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -33,13 +33,16 @@ export function Switch({
|
||||
// On: the solid accent (white knob clears 3:1 in every theme). Off: a
|
||||
// border-light track with an inset fg-muted/80 ring (≥3:1 in every theme), so the control's edge
|
||||
// is visible on any surface (surface-4 alone was ~1:1 on a light card).
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
checked ? "bg-accent-solid" : "bg-border-light ring-1 ring-inset ring-fg-muted/80"
|
||||
// Hover darkens the track (enabled only), so it reads as clickable.
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
checked
|
||||
? "bg-accent-solid enabled:hover:bg-accent-solid-hover"
|
||||
: "bg-border-light ring-1 ring-inset ring-fg-muted/80 enabled:hover:ring-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out ${
|
||||
checked ? "translate-x-[18px]" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -4,18 +4,58 @@
|
||||
* (`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
|
||||
* (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.
|
||||
* @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 { Check, Loader2, X } from "lucide-react";
|
||||
import { api, type LaneSourcesView } from "../../lib/api";
|
||||
import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api";
|
||||
|
||||
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;
|
||||
};
|
||||
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({
|
||||
laneId,
|
||||
parent,
|
||||
@@ -31,6 +71,24 @@ export function CloneSourcesForm({
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [login, setLogin] = useState<Login | null>(null);
|
||||
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) => [
|
||||
...new Set(
|
||||
@@ -44,11 +102,19 @@ export function CloneSourcesForm({
|
||||
const cloneAll = async (list: string[]) => {
|
||||
if (busy || !list.length) return;
|
||||
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]);
|
||||
show();
|
||||
let asked: Login | null = null;
|
||||
for (const row of next) {
|
||||
if (row.state === "failed") continue;
|
||||
row.state = "busy";
|
||||
show();
|
||||
try {
|
||||
@@ -80,6 +146,7 @@ export function CloneSourcesForm({
|
||||
};
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
row.note = t("clone.sshRefused", { host });
|
||||
row.ssh = true;
|
||||
} else {
|
||||
row.note = e.message || String(err);
|
||||
}
|
||||
@@ -99,6 +166,18 @@ export function CloneSourcesForm({
|
||||
|
||||
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 (
|
||||
<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>
|
||||
@@ -112,6 +191,29 @@ export function CloneSourcesForm({
|
||||
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
|
||||
{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 && (
|
||||
<div className="space-y-1.5 rounded border border-border-light p-2">
|
||||
<div className="flex gap-2">
|
||||
@@ -178,7 +280,12 @@ export function CloneSourcesForm({
|
||||
{r.url}
|
||||
</span>
|
||||
</span>
|
||||
{r.note && <span className="ml-[18px] block text-status-danger">{r.note}</span>}
|
||||
{r.note && (
|
||||
<span className="ml-[18px] block text-status-danger">
|
||||
{r.note}
|
||||
{r.ssh && <> {settingsLink}</>}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
* first, and a session some tab already holds opens THAT tab instead of a
|
||||
* 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
|
||||
* it is live the composer is off. Only Claude and Codex can resume a session by id —
|
||||
* any other tool's session offers a new session from the workspace home
|
||||
* it is live the composer is off. Only Claude, Codex and a catalog tool with
|
||||
* `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
|
||||
* (`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
|
||||
@@ -32,6 +33,7 @@ import { toast } from "../../lib/toast";
|
||||
import i18n from "../../i18n";
|
||||
import { liveRunFor } from "./WorkspaceSessionList";
|
||||
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
|
||||
* 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). */
|
||||
// Before the catalog loads: the two hand-written launchers resume by id.
|
||||
const RESUMABLE = new Set(["claude", "codex"]);
|
||||
|
||||
export function SessionReplay({
|
||||
@@ -65,6 +68,7 @@ export function SessionReplay({
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const { byId } = useAiTools();
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
@@ -88,7 +92,7 @@ export function SessionReplay({
|
||||
}, [sessionId]);
|
||||
|
||||
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 text = promptWithImages(prompt, images);
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file SessionResumeButton.tsx
|
||||
* @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
|
||||
* transcript into context. Fork resumes it into a NEW session id
|
||||
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
|
||||
@@ -26,6 +27,7 @@ import { ConfirmModal } from "../ConfirmModal";
|
||||
import { startInFreeTabDetailed } from "./startSession";
|
||||
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
|
||||
/** A path without its trailing separators — `/` or a Windows `\`. */
|
||||
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
||||
@@ -54,6 +56,7 @@ export function SessionResumeButton({
|
||||
const navigate = useNavigate();
|
||||
const { lanes } = useLanes();
|
||||
const { canEditLane } = usePermissions();
|
||||
const { byId } = useAiTools();
|
||||
const [runs, setRuns] = useState<RunHandle[] | null>(null);
|
||||
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -70,12 +73,13 @@ export function SessionResumeButton({
|
||||
};
|
||||
}, [session.id]);
|
||||
|
||||
// Claude only: a Codex session id is not what
|
||||
// `codex resume` takes for every kind of thread.
|
||||
// Claude, or a catalog tool that resumes by id (no launcher of its own): a
|
||||
// Codex session id is not what `codex resume` takes for every kind of thread.
|
||||
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.
|
||||
const lane =
|
||||
tool !== "claude" || !session.cwd
|
||||
(tool !== "claude" && !catalogResume) || !session.cwd
|
||||
? null
|
||||
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
|
||||
owningLane(session.cwd, tool, lanes));
|
||||
@@ -140,7 +144,7 @@ export function SessionResumeButton({
|
||||
{t("sessions.resume")}
|
||||
</button>
|
||||
)}
|
||||
{resumable && (
|
||||
{resumable && tool === "claude" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming("fork")}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @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
|
||||
* 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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -11,16 +13,20 @@ import userEvent from "@testing-library/user-event";
|
||||
import "../../../i18n";
|
||||
|
||||
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 { CloneSourcesForm } from "../CloneSourcesForm";
|
||||
import { api, type GitIdentity, type LaneSourcesView } from "../../../lib/api";
|
||||
import { CloneSourcesForm, cloneTarget } from "../CloneSourcesForm";
|
||||
|
||||
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
||||
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
@@ -58,4 +64,36 @@ describe("CloneSourcesForm", () => {
|
||||
});
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
||||
});
|
||||
|
||||
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" });
|
||||
expect(await screen.findByText(/has no SSH key yet/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,17 +9,24 @@
|
||||
* boot/login while the switch is on. Between them, the status-area icon switch
|
||||
* (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js). Last,
|
||||
* **Uninstall AgentHub**: `POST /api/settings/uninstall` removes AgentHub from
|
||||
* the machine, database included (scripts/uninstall.js).
|
||||
* the machine, database included (scripts/uninstall.js). Quit is a plain
|
||||
* button and Uninstall a danger one, both the same width with an icon, and
|
||||
* each spins while its request runs.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2, Power, Trash2 } from "lucide-react";
|
||||
import { api, type AutostartStatus, type TrayStatus } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { SettingsRow, SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
// The two action buttons share one width, so they line up under the switches.
|
||||
const ACTION = "min-w-[6.5rem] justify-center text-xs";
|
||||
const ICON = "h-3.5 w-3.5 shrink-0";
|
||||
|
||||
export function AutostartSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [status, setStatus] = useState<AutostartStatus | null>(null);
|
||||
@@ -126,9 +133,11 @@ export function AutostartSettings() {
|
||||
type="button"
|
||||
data-testid="quit-agenthub"
|
||||
disabled={quitting}
|
||||
aria-busy={quitting}
|
||||
onClick={() => void quit()}
|
||||
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||
className={`btn-secondary ${ACTION}`}
|
||||
>
|
||||
{quitting ? <Loader2 className={ICON + " animate-spin"} /> : <Power className={ICON} />}
|
||||
{t("quit.button")}
|
||||
</button>
|
||||
}
|
||||
@@ -141,9 +150,15 @@ export function AutostartSettings() {
|
||||
type="button"
|
||||
data-testid="uninstall-agenthub"
|
||||
disabled={uninstalling}
|
||||
aria-busy={uninstalling}
|
||||
onClick={() => void uninstall()}
|
||||
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||
className={`btn-danger ${ACTION}`}
|
||||
>
|
||||
{uninstalling ? (
|
||||
<Loader2 className={ICON + " animate-spin"} />
|
||||
) : (
|
||||
<Trash2 className={ICON} />
|
||||
)}
|
||||
{t("uninstall.button")}
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* switch row (`SettingsSwitchRow`) and a segmented radio group
|
||||
* (`SettingsSegmented`, arrow keys + one roving tab stop). Rows divide
|
||||
* themselves with a hairline, none after the last, so a section is just its
|
||||
* rows in order.
|
||||
* rows in order. A string description shows its `backticked` words as code,
|
||||
* and a `disabled` row dims its label, so an unavailable control reads as one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -32,30 +33,55 @@ export function SettingsSection({
|
||||
);
|
||||
}
|
||||
|
||||
/** `text` with every `backticked` run drawn as inline code (a command to type). */
|
||||
export function withCode(text: string): ReactNode {
|
||||
const parts = text.split(/`([^`]+)`/);
|
||||
if (parts.length === 1) return text;
|
||||
return parts.map((p, i) =>
|
||||
i % 2 ? (
|
||||
<code
|
||||
key={i}
|
||||
className="whitespace-nowrap rounded border border-border bg-surface-2 px-1 py-px font-mono text-[11px] text-fg-secondary"
|
||||
>
|
||||
{p}
|
||||
</code>
|
||||
) : (
|
||||
p
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsRow({
|
||||
label,
|
||||
description,
|
||||
control,
|
||||
alignTop = false,
|
||||
disabled = false,
|
||||
id,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
control: ReactNode;
|
||||
alignTop?: boolean;
|
||||
/** The control cannot be used here: the label is dimmed with it. */
|
||||
disabled?: boolean;
|
||||
/** A deep-link anchor (`#id`) for one row inside a grouped section. */
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${
|
||||
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-6 border-b border-border/60 last:border-b-0 ${
|
||||
description ? "py-3" : "py-2"
|
||||
} ${alignTop ? "items-start" : "items-center"}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-fg-secondary">{label}</p>
|
||||
{description && <p className="mt-0.5 text-xs text-fg-muted">{description}</p>}
|
||||
<p className={`text-sm ${disabled ? "text-fg-muted" : "text-fg-secondary"}`}>{label}</p>
|
||||
{description && (
|
||||
<p className="mt-0.5 text-xs leading-relaxed text-fg-muted">
|
||||
{typeof description === "string" ? withCode(description) : description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0">{control}</div>
|
||||
</div>
|
||||
@@ -82,6 +108,7 @@ export function SettingsSwitchRow({
|
||||
id={id}
|
||||
label={label}
|
||||
description={description}
|
||||
disabled={disabled}
|
||||
control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -8,26 +8,31 @@
|
||||
* `.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
|
||||
* 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>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AccountPin,
|
||||
type AccountPool,
|
||||
type PoolAccountInput,
|
||||
type PoolProfile,
|
||||
type PoolTool,
|
||||
type ServerUserUsage,
|
||||
} from "../../lib/api";
|
||||
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
|
||||
type Row = {
|
||||
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 {
|
||||
const err = e as Error & { code?: string };
|
||||
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 [adding, setAdding] = useState(false);
|
||||
const [removing, setRemoving] = useState<Row | null>(null);
|
||||
const [editing, setEditing] = useState<Row | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
(force = false) =>
|
||||
@@ -148,6 +156,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||
</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 className="px-5 py-2.5 text-sm text-fg-secondary">
|
||||
{t(`pool.kind.${r.kind}`)}
|
||||
@@ -188,7 +206,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
</div>
|
||||
</td>
|
||||
{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
|
||||
type="button"
|
||||
onClick={() => setRemoving(r)}
|
||||
@@ -206,7 +233,6 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adding && (
|
||||
<AddAccountDialog
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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";
|
||||
|
||||
function AddAccountDialog({
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -19,10 +20,12 @@ const accountPool = vi.hoisted(() => ({
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
profiles: vi.fn(),
|
||||
setUsers: vi.fn(),
|
||||
}));
|
||||
const users = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { accountPool } };
|
||||
return { ...actual, api: { accountPool, users } };
|
||||
});
|
||||
|
||||
import { AccountPoolCard } from "../AccountPoolCard";
|
||||
@@ -66,6 +69,12 @@ const POOL: AccountPool = {
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(accountPool)) fn.mockReset();
|
||||
accountPool.list.mockResolvedValue(POOL);
|
||||
users.list.mockResolvedValue({
|
||||
users: [
|
||||
{ id: 7, username: "mia", disabled: false },
|
||||
{ id: 8, username: "leo", disabled: false },
|
||||
],
|
||||
});
|
||||
accountPool.profiles.mockResolvedValue({
|
||||
profiles: [
|
||||
{
|
||||
@@ -158,4 +167,43 @@ describe("AccountPoolCard", () => {
|
||||
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,7 @@
|
||||
"clone.remember": "Remember this login for my account",
|
||||
"clone.sshRefused": "{{host}} refused the SSH login. Add your public key to that host, or make one in Settings → Git.",
|
||||
"clone.sshSettings": "Open Settings → Git",
|
||||
"clone.sshNoKey": "Not cloned: your account has no SSH key yet. Make one in Settings → Git, add its public key to {{host}}, then retry.",
|
||||
"moreActions": "More actions",
|
||||
"pipelinePicker.label": "Pipeline template",
|
||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
|
||||
@@ -421,6 +422,9 @@
|
||||
"home.cloneRun": "Clone",
|
||||
"home.cloneRetry": "Retry the failed ones",
|
||||
"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.remove": "Remove",
|
||||
"home.missing": "missing",
|
||||
|
||||
@@ -166,7 +166,8 @@
|
||||
"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.",
|
||||
"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",
|
||||
"sources": {
|
||||
@@ -196,7 +197,20 @@
|
||||
"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."
|
||||
},
|
||||
"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": {
|
||||
"button": "Delete user",
|
||||
|
||||
@@ -220,6 +220,7 @@
|
||||
"clone.remember": "Lưu đăng nhập này cho tài khoản của tôi",
|
||||
"clone.sshRefused": "{{host}} từ chối đăng nhập SSH. Hãy thêm public key của bạn vào host đó, hoặc tạo key trong Cài đặt → Git.",
|
||||
"clone.sshSettings": "Mở Cài đặt → Git",
|
||||
"clone.sshNoKey": "Chưa clone: tài khoản của bạn chưa có SSH key. Hãy tạo key trong Cài đặt → Git, thêm public key vào {{host}}, rồi thử lại.",
|
||||
"moreActions": "Thêm hành động",
|
||||
"pipelinePicker.label": "Mẫu pipeline",
|
||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
|
||||
@@ -421,6 +422,9 @@
|
||||
"home.cloneRun": "Clone",
|
||||
"home.cloneRetry": "Thử lại các repo lỗi",
|
||||
"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.remove": "Gỡ",
|
||||
"home.missing": "không còn",
|
||||
|
||||
@@ -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.",
|
||||
"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.",
|
||||
"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",
|
||||
"sources": {
|
||||
@@ -196,7 +197,20 @@
|
||||
"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."
|
||||
},
|
||||
"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": {
|
||||
"button": "Xoá user",
|
||||
|
||||
+26
-1
@@ -676,9 +676,18 @@ export type CodexPoolAccount = {
|
||||
quotaPercent: number | null;
|
||||
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 = {
|
||||
accounts: PoolAccount[];
|
||||
codex: CodexPoolAccount[];
|
||||
/** Per tool; absent from an older server, empty in local mode. */
|
||||
pins?: { claude: AccountPin[]; codex: AccountPin[] };
|
||||
blockPercent: number;
|
||||
};
|
||||
export type PoolTool = "claude" | "codex";
|
||||
@@ -1052,6 +1061,20 @@ export const api = {
|
||||
method: "POST",
|
||||
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. */
|
||||
remove: (tool: PoolTool, name: string, currentPassword: string) =>
|
||||
request<{ ok: true }>(`/accounts/${encodeURIComponent(name)}`, {
|
||||
@@ -4406,6 +4429,8 @@ export interface AiTool {
|
||||
prompt: { mode: "argv" | "flag" | "after-start" } | null;
|
||||
/** Offers "Resume last session". */
|
||||
resume: boolean;
|
||||
/** Can resume a NAMED session (Claude, Codex, a catalog `resumeById`). */
|
||||
resumeById?: boolean;
|
||||
/** Offers the per-run "Bypass permissions" switch. */
|
||||
bypass: boolean;
|
||||
/** Can use an AI Proxy profile (Claude, Codex); others are native only. */
|
||||
@@ -5189,7 +5214,7 @@ export interface RunStartArgs {
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
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;
|
||||
effort?: EffortLevel;
|
||||
/** Sent as `claude`'s first positional message once the pane boots; omit
|
||||
|
||||
@@ -732,7 +732,10 @@ export interface Session {
|
||||
/** The AI coding CLI that produced the session: `"claude"` (the default —
|
||||
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
|
||||
* 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;
|
||||
/** The workspace (lane id) that owns the session — the one whose pane held
|
||||
* it; null for a session no workspace started.
|
||||
|
||||
@@ -7022,7 +7022,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7033,7 +7033,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
Start AgentHub automatically
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
|
||||
>
|
||||
Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.
|
||||
</p>
|
||||
@@ -7044,19 +7044,19 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-label="Start AgentHub automatically"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 bg-border-light ring-1 ring-inset ring-fg-muted/80 enabled:hover:ring-fg-secondary"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out translate-x-0.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7067,7 +7067,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
Show AgentHub icon in the status area
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
|
||||
>
|
||||
An AgentHub icon beside the clock (top bar, menu bar or notification area) to open the dashboard, quit it, and start it again after a quit.
|
||||
</p>
|
||||
@@ -7078,19 +7078,19 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<button
|
||||
aria-checked="true"
|
||||
aria-label="Show AgentHub icon in the status area"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-accent-solid"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 bg-accent-solid enabled:hover:bg-accent-solid-hover"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-[18px]"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out translate-x-[18px]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7101,25 +7101,51 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
Quit AgentHub
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
|
||||
>
|
||||
Stop the dashboard so nothing runs in the background. Opening Claude Code no longer starts it; run \`agenthub start\` to bring it back.
|
||||
Stop the dashboard so nothing runs in the background. Opening Claude Code no longer starts it; run
|
||||
<code
|
||||
class="whitespace-nowrap rounded border border-border bg-surface-2 px-1 py-px font-mono text-[11px] text-fg-secondary"
|
||||
>
|
||||
agenthub start
|
||||
</code>
|
||||
to bring it back.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<button
|
||||
class="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||
aria-busy="false"
|
||||
class="btn-secondary min-w-[6.5rem] justify-center text-xs"
|
||||
data-testid="quit-agenthub"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-power h-3.5 w-3.5 shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 2v10"
|
||||
/>
|
||||
<path
|
||||
d="M18.4 6.6a9 9 0 1 1-12.77.04"
|
||||
/>
|
||||
</svg>
|
||||
Quit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7130,7 +7156,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
Uninstall AgentHub
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
|
||||
>
|
||||
Remove AgentHub from this computer: the dashboard, its database and settings, its hooks in Claude Code, Codex and Antigravity, and the plugin. Your conversations, workspace folders and repositories are kept.
|
||||
</p>
|
||||
@@ -7139,10 +7165,45 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
class="shrink-0"
|
||||
>
|
||||
<button
|
||||
class="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||
aria-busy="false"
|
||||
class="btn-danger min-w-[6.5rem] justify-center text-xs"
|
||||
data-testid="uninstall-agenthub"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-trash2 h-3.5 w-3.5 shrink-0"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 6h18"
|
||||
/>
|
||||
<path
|
||||
d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"
|
||||
/>
|
||||
<path
|
||||
d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"
|
||||
/>
|
||||
<line
|
||||
x1="10"
|
||||
x2="10"
|
||||
y1="11"
|
||||
y2="17"
|
||||
/>
|
||||
<line
|
||||
x1="14"
|
||||
x2="14"
|
||||
y1="11"
|
||||
y2="17"
|
||||
/>
|
||||
</svg>
|
||||
Uninstall
|
||||
</button>
|
||||
</div>
|
||||
@@ -7162,7 +7223,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7173,12 +7234,12 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
Release
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
|
||||
>
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.8 (rebuild client to match)
|
||||
UI build v2.8.11 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -7193,7 +7254,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7215,7 +7276,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7240,7 +7301,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7262,7 +7323,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7284,7 +7345,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7295,7 +7356,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
Live connections
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
|
||||
>
|
||||
Browser tabs and tools receiving live updates
|
||||
</p>
|
||||
@@ -7325,7 +7386,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7355,7 +7416,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7366,7 +7427,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
Database file
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
|
||||
>
|
||||
<span
|
||||
class="font-mono break-all"
|
||||
@@ -7386,7 +7447,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -7408,7 +7469,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -8960,7 +9021,7 @@ exit 0
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -8971,7 +9032,7 @@ exit 0
|
||||
Redact secrets before storing
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
class="mt-0.5 text-xs leading-relaxed text-fg-muted"
|
||||
>
|
||||
API keys and tokens of known shapes, bearer headers, private keys and password=/TOKEN= values in event payloads and summaries become [redacted]. The transcripts on disk are the CLIs' own files and are not changed.
|
||||
</p>
|
||||
@@ -8982,13 +9043,13 @@ exit 0
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-label="Redact secrets before storing"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 bg-border-light ring-1 ring-inset ring-fg-muted/80 enabled:hover:ring-fg-secondary"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out translate-x-0.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -9739,7 +9800,7 @@ exports[`screen snapshots > Settings — notifications 1`] = `
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
@@ -9756,13 +9817,13 @@ exports[`screen snapshots > Settings — notifications 1`] = `
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-label="Enable Browser Notifications"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 bg-border-light ring-1 ring-inset ring-fg-muted/80 enabled:hover:ring-fg-secondary"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out translate-x-0.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+4
-4
@@ -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.
|
||||
|
||||
`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`):
|
||||
|
||||
@@ -1593,12 +1593,12 @@ What a workspace's home page edits (`server/lib/lane-sources.js`, `server/routes
|
||||
|
||||
- **Instructions** — the `instructions` key is required (`400 EBADFIELD` without it), a string or `null`; blank clears; over 16 000 characters is `413 ETOOBIG`. The lane is re-broadcast (`lane_update`). The text is stored in `lanes.instructions` but is NOT part of the lane payload (`GET /api/lanes`, `lane_update`) — read it here.
|
||||
- **Folder** — must be absolute, exist and be a directory (`400 EBADPATH`), must not be or contain the OS home (`400 EBADHOME`), and must not be the workspace's own folder or one it already has (`400 EDUP`). Stored by real path. Removing it only drops the row.
|
||||
- **Clone** — clones a related repo BESIDE the workspace folder (into its parent; no path is taken from the request) and adds it as a Folder. The clone is `POST /api/lanes/clone`'s own: the same URL allowlist (`400 EBADURL`), one plain `name` (default: the repo's), `409 EEXISTS` for a taken name, run as the folder's account with that account's git identity, `409 ECREDENTIALS` (with `auth: {protocol, host}`) / `ESSHAUTH` / `ENOREPO`; a tool that takes no folders is `400 EUNSUPPORTED`. Answers the view plus `path`. Removing the folder later never deletes the clone. The workspace home's **Add folder** form takes several URLs, one per line, and clones them in turn.
|
||||
- **Clone** — clones a related repo BESIDE the workspace folder (into its parent; no path is taken from the request) and adds it as a Folder. The clone is `POST /api/lanes/clone`'s own: the same URL allowlist (`400 EBADURL`), one plain `name` (default: the repo's), `409 EEXISTS` for a taken name, run as the folder's account with that account's git identity, `409 ECREDENTIALS` (with `auth: {protocol, host}`) / `ESSHAUTH` / `ENOREPO`; a tool that takes no folders is `400 EUNSUPPORTED`. Answers the view plus `path`. Removing the folder later never deletes the clone. The workspace home's **Add folder** form takes several URLs, one per line, and clones them in turn. Before cloning it checks each URL against `GET /api/me/git`: an SSH link (`ssh://…`, `user@host:path`) while the account has no SSH key is not sent at all and points to Settings → Git (make a key, add its public key to the host), and an HTTPS host with no saved login is flagged with an optional login row — a public repo needs none. The server's own `ECREDENTIALS`/`ESSHAUTH` answers still decide.
|
||||
- **File** — up to 10 MB (`413 ETOOBIG`), not empty (`400 EEMPTY`). AgentHub stores it under `<data dir>/workspace-context/<lane id>/` with a name it derives from `X-File-Name` (directory parts and path characters dropped, `name (2).ext` on a clash), so a request never names a path. It is a COPY taken at upload: later edits to the original are not seen — remove and upload it again, or add its directory as a Folder (read live from disk). Removing it deletes that copy. At most 50 sources per workspace (`400 ETOOMANY`).
|
||||
- **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `<CLAUDE_HOME>/projects/<encoded cwd>/memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content.
|
||||
- Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`.
|
||||
|
||||
At spawn (`spawnRun`, both `POST /api/lanes/:id/start` and `POST /api/run`) the pane gets, from these rows: Claude — `--add-dir <folders…> [<context dir>]` first, then `--append-system-prompt-file=<data dir>/workspace-prompts/<lane id>.md`, a file rewritten at every spawn holding the workspace owner's own instructions (`PUT /api/me/instructions`) + the workspace's instructions + the context file list + the extra folders; Codex — one `--add-dir <dir>` per folder (and the context dir) and `-c developer_instructions=<same text as a TOML string>` (above 6 KB as the escaped TOML string, one line pointing at that file instead; nothing when that file could not be written). Any other tool gets none of it (`supported: false`). A folder or file that vanished is skipped.
|
||||
At spawn (`spawnRun`, both `POST /api/lanes/:id/start` and `POST /api/run`) the pane gets, from these rows: Claude — `--add-dir <folders…> [<context dir>]` first, then that workspace's instructions folder `<data dir>/workspace-prompts/<lane id>/` (a member's: `prompt-<lane id>/` in their handoff dir) as one more `--add-dir`, with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` in the pane's env so Claude reads the `CLAUDE.md` there as memory — never `--append-system-prompt-file`: Claude cannot carry a custom system prompt over a restart and refused `/tui` in such a pane. That `CLAUDE.md` is rewritten at every spawn and holds the workspace owner's own instructions (`PUT /api/me/instructions`) + the workspace's instructions + the context file list + the extra folders; Codex — one `--add-dir <dir>` per folder (and the context dir) and `-c developer_instructions=<same text as a TOML string>` (above 6 KB as the escaped TOML string, one line pointing at that file instead; nothing when that file could not be written). Any other tool gets none of it (`supported: false`). A folder or file that vanished is skipped.
|
||||
|
||||
#### Projects (workspaces grouped by repository)
|
||||
|
||||
|
||||
+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 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 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 |
|
||||
|
||||
### 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` |
|
||||
| `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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
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_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_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
|
||||
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
|
||||
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
||||
[`docs/LANES.md`](LANES.md#custom-pipeline-templates)); until then
|
||||
|
||||
@@ -355,6 +355,7 @@ claude setup-token
|
||||
agenthub accounts add team-1
|
||||
agenthub accounts list # live panes, $ and tokens in the last 5 h / 7 d, quota, state
|
||||
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
|
||||
@@ -423,6 +424,28 @@ How a new session picks its account (`server/lib/claude-pool.js`):
|
||||
pane still needs that member's own `/login`.
|
||||
- A token more than a year old is skipped.
|
||||
- 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
|
||||
5 minutes. If the usage endpoint refuses a setup-token, the state column shows
|
||||
`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 list --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
|
||||
|
||||
+146
-1
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.8.8
|
||||
version: 2.8.11
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -4785,6 +4785,22 @@ components:
|
||||
description: Events attributed to this run's inner agents, ordered by created_at then id. Capped at 5000 rows.
|
||||
items:
|
||||
$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:
|
||||
type: object
|
||||
required:
|
||||
@@ -15141,6 +15157,18 @@ paths:
|
||||
nullable: true
|
||||
full:
|
||||
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:
|
||||
type: array
|
||||
description: The Codex account pool (server/lib/codex-pool.js), same fields plus `kind`; no expiry
|
||||
@@ -15241,6 +15269,62 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$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:
|
||||
get:
|
||||
tags:
|
||||
@@ -15250,6 +15334,67 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
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}:
|
||||
delete:
|
||||
tags:
|
||||
|
||||
Generated
+3
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.8",
|
||||
"version": "2.8.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.8",
|
||||
"version": "2.8.11",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
@@ -14,6 +14,7 @@
|
||||
"adm-zip": "^0.6.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"multer": "^2.0.0",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"systray2": "^2.1.4",
|
||||
@@ -27,7 +28,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"prettier": "^3.8.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -215,7 +215,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
@@ -1099,7 +1098,6 @@
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.8",
|
||||
"version": "2.8.11",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
@@ -75,6 +75,7 @@
|
||||
"adm-zip": "^0.6.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"multer": "^2.0.0",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"systray2": "^2.1.4",
|
||||
@@ -88,7 +89,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"prettier": "^3.8.1"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -638,6 +638,33 @@ An `antigravity` workspace carries no pipeline either.
|
||||
`DASHBOARD_CURSOR_POLL_MS` ms (default 15000, `0` disables) into `sessions`
|
||||
rows (`tool = 'cursor'`) — no `token_usage`, the transcripts carry none —
|
||||
and `cursor_transcripts` holds the mtime read for each file.
|
||||
- **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
|
||||
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
|
||||
|
||||
@@ -190,16 +190,16 @@ describe("install / update / icons", () => {
|
||||
it("starts an install job and reports it", async () => {
|
||||
jobs.__reset();
|
||||
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.body.job.tool, "copilot");
|
||||
assert.equal(r.body.job.tool, "codex");
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "ok");
|
||||
assert.deepEqual(spawned.at(-1), [
|
||||
path.join(BIN, "npm"),
|
||||
"install",
|
||||
"-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");
|
||||
assert.equal(notNpm.status, 409);
|
||||
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",
|
||||
});
|
||||
assert.equal(cross.status, 403);
|
||||
@@ -225,7 +225,7 @@ describe("install / update / icons", () => {
|
||||
it("answers EBUSY while a job runs", async () => {
|
||||
jobs.__reset({ timeoutMs: 50 });
|
||||
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");
|
||||
assert.equal(busy.status, 409);
|
||||
assert.equal(busy.body.error.code, "EBUSY");
|
||||
|
||||
@@ -194,6 +194,7 @@ describe("enable / default", () => {
|
||||
launcher: null,
|
||||
prompt: { mode: "flag" },
|
||||
resume: false,
|
||||
resumeById: false,
|
||||
bypass: false,
|
||||
proxyCapable: false,
|
||||
modelFlag: false,
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -188,6 +190,51 @@ describe("a pool login", () => {
|
||||
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", () => {
|
||||
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
|
||||
assert.throws(() => pool.addLoginAccount("team-2", { oauth: oauth("b"), account: ACCOUNT }), {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -181,12 +181,16 @@ describe("lane-sources instructions and launch", () => {
|
||||
assert.equal(lanesLib.getLane(lane.id).instructions, "- always answer in Vietnamese");
|
||||
});
|
||||
|
||||
it("starts a claude pane with --add-dir first and the instructions from a file", () => {
|
||||
it("starts a claude pane with --add-dir first and the instructions as a CLAUDE.md there", () => {
|
||||
let argv = null;
|
||||
let newSession = null;
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
|
||||
// The CLI rides behind `sh -c <cd; wait for a client> sh <cwd>` (pty-run.js).
|
||||
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 6);
|
||||
if (args[0] === "new-session") {
|
||||
newSession = args;
|
||||
argv = args.slice(args.indexOf("--") + 6);
|
||||
}
|
||||
return "";
|
||||
});
|
||||
pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" });
|
||||
@@ -194,11 +198,15 @@ describe("lane-sources instructions and launch", () => {
|
||||
assert.equal(argv[0], "claude");
|
||||
assert.equal(argv[1], "--add-dir");
|
||||
assert.deepEqual(argv.slice(2, 4), [extra, src.contextDir(lane.id)]);
|
||||
assert.equal(argv[4], "--permission-mode");
|
||||
const flag = argv.find((a) => a.startsWith("--append-system-prompt-file="));
|
||||
const file = flag.slice("--append-system-prompt-file=".length);
|
||||
assert.ok(file.startsWith(process.env.DASHBOARD_DATA_DIR));
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
// The instructions' own folder, read as memory: a custom system prompt
|
||||
// (`--append-system-prompt-file`) made Claude refuse `/tui` in the pane.
|
||||
const dir = argv[4];
|
||||
assert.ok(dir.startsWith(process.env.DASHBOARD_DATA_DIR));
|
||||
assert.equal(argv[5], "--permission-mode");
|
||||
assert.ok(!argv.some((a) => a.startsWith("--append-system-prompt")));
|
||||
assert.ok(newSession.includes("CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1"));
|
||||
assert.deepEqual(fs.readdirSync(dir), ["CLAUDE.md"]);
|
||||
const text = fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8");
|
||||
assert.match(text, /^- always answer in Vietnamese/);
|
||||
assert.match(text, /pass wd\.md/);
|
||||
// A prompt that looks like flags stays one positional, never `claude -c`.
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
|
||||
@@ -733,7 +733,7 @@ describe("pty-run", () => {
|
||||
let savedPath;
|
||||
before(() => {
|
||||
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.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", () => {
|
||||
for (const tool of ["claude", "codex", "antigravity"]) {
|
||||
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
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -144,3 +145,112 @@ describe("the account pool from the Users page", () => {
|
||||
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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,7 +114,7 @@ describe("a member pane's files live in the member's handoff dir", () => {
|
||||
assert.deepEqual(x.addDirs, [ctx]);
|
||||
assert.equal(fs.readFileSync(path.join(ctx, "spec.md"), "utf8"), "# spec\n");
|
||||
assert.equal(mode(path.join(ctx, "spec.md")), 0o644);
|
||||
assert.equal(x.systemPromptFile, path.join(HANDOFF, `prompt-${laneId}.md`));
|
||||
assert.equal(x.systemPromptFile, path.join(HANDOFF, `prompt-${laneId}`, "CLAUDE.md"));
|
||||
assert.equal(mode(x.systemPromptFile), 0o644);
|
||||
const text = fs.readFileSync(x.systemPromptFile, "utf8");
|
||||
assert.match(text, /Be brief\./);
|
||||
|
||||
@@ -183,9 +183,18 @@ describe("tmux wrapper", () => {
|
||||
calls = fake(true);
|
||||
tmux.enableMouse("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");
|
||||
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], [
|
||||
"set-option",
|
||||
"-t",
|
||||
@@ -235,7 +244,39 @@ describe("tmux wrapper", () => {
|
||||
...["set-option", "-t", "=agenthub-lane-1:", "pane-scrollbars-style"],
|
||||
"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)", () => {
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
|
||||
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": [
|
||||
"--continue"
|
||||
],
|
||||
"resumeById": [
|
||||
"--resume"
|
||||
],
|
||||
"bypass": [
|
||||
"--yolo"
|
||||
],
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"resume": [
|
||||
"--continue"
|
||||
],
|
||||
"resumeById": [
|
||||
"--resume"
|
||||
],
|
||||
"bypass": [
|
||||
"--permission-mode",
|
||||
"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": {
|
||||
"mode": "argv"
|
||||
},
|
||||
"model": {
|
||||
"flag": "--model"
|
||||
},
|
||||
"resume": [
|
||||
"--continue"
|
||||
],
|
||||
"resumeById": [
|
||||
"--resume"
|
||||
],
|
||||
"bypass": [
|
||||
"--auto-approve"
|
||||
],
|
||||
"homepageUrl": "https://omp.sh",
|
||||
"faviconDomain": "omp.sh",
|
||||
"install": {
|
||||
"kind": "script",
|
||||
"url": "https://omp.sh/install"
|
||||
},
|
||||
"update": {
|
||||
"kind": "script"
|
||||
}
|
||||
},
|
||||
"config": [
|
||||
{
|
||||
"path": "~/.omp/agent/config.yml",
|
||||
"format": "text"
|
||||
},
|
||||
{
|
||||
"path": "~/.omp/agent/models.yml",
|
||||
"format": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
"model": {
|
||||
"flag": "--model"
|
||||
},
|
||||
"resume": [
|
||||
"--continue"
|
||||
],
|
||||
"resumeById": [
|
||||
"--session"
|
||||
],
|
||||
"homepageUrl": "https://opencode.ai/docs/cli/",
|
||||
"install": {
|
||||
"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
|
||||
// (server/lib/cursor-usage.js) — the same "deleted stays deleted" rule.
|
||||
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();
|
||||
}
|
||||
}
|
||||
// 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
|
||||
// a VACUUM, which rewrites the whole file under an exclusive lock. Done here,
|
||||
|
||||
@@ -766,6 +766,18 @@ function startBackgroundServices() {
|
||||
} catch (err) {
|
||||
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
|
||||
// content names this build; point it here (the command in the tool's
|
||||
// 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 (!isStrArray(doc.detect, CMD_RE) || !doc.detect.length) 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.bypassEnv !== undefined) {
|
||||
@@ -145,6 +145,9 @@ function validate(doc, source) {
|
||||
faviconDomain: typeof doc.faviconDomain === "string" ? doc.faviconDomain : null,
|
||||
prompt,
|
||||
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,
|
||||
bypassEnv: doc.bypassEnv || null,
|
||||
launcher,
|
||||
@@ -416,6 +419,8 @@ function view() {
|
||||
launcher: t.launcher,
|
||||
prompt: t.prompt ? { mode: t.prompt.mode } : null,
|
||||
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),
|
||||
proxyCapable: PROXY_CAPABLE.has(t.id),
|
||||
// claude/codex take a model through their own argv builders.
|
||||
|
||||
@@ -367,6 +367,7 @@ function deleteUser(username) {
|
||||
} catch {
|
||||
/* no personal profiles */
|
||||
}
|
||||
sql("DELETE FROM account_pins WHERE user_id = ?").run(u.id);
|
||||
sql("DELETE FROM users WHERE id = ?").run(u.id);
|
||||
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
|
||||
* sessions its runs (`dashboard_runs.account`) held, over the hours they
|
||||
* 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
|
||||
* every `QUOTA_TTL_MS`; a token the usage endpoint refuses simply has no
|
||||
* reading, and ranking falls back to spend.
|
||||
@@ -193,6 +197,7 @@ function removeAccount(name) {
|
||||
}
|
||||
}
|
||||
fs.rmSync(fileOf(n));
|
||||
require("./account-pins").dropAccount("claude", 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 ──────────────────────────────────────────
|
||||
|
||||
/** 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));
|
||||
if (usable.length === 0) return null;
|
||||
let live = null;
|
||||
if (!member) {
|
||||
// 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:
|
||||
@@ -453,10 +497,18 @@ function pick(member) {
|
||||
} else {
|
||||
// One .credentials.json per home: while a pane of theirs runs on a
|
||||
// pool login, the next pane stays on that one.
|
||||
const live = usable.find((a) => a.kind === "login" && liveInHome(a.name, member));
|
||||
if (live) return live;
|
||||
// The login in the home is the one they run on — after an admin's
|
||||
// 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;
|
||||
refreshQuotas().catch(() => {});
|
||||
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
|
||||
* only when it is the host's own login (`hostHolds`), which stays as it is.
|
||||
*/
|
||||
function pickAccount(member = null) {
|
||||
const a = pick(member);
|
||||
function pickAccount(member = null, ownerId = null) {
|
||||
const a = pick(member, ownerId);
|
||||
if (!a) return null;
|
||||
return a.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
|
||||
* before the pane starts and writes a login into the member's home.
|
||||
*/
|
||||
function forSpawn(member = null) {
|
||||
const a = pick(member);
|
||||
function forSpawn(member = null, ownerId = null) {
|
||||
const a = pick(member, ownerId);
|
||||
if (!a) return null;
|
||||
if (a.kind === "token") {
|
||||
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 = {
|
||||
BLOCK_PERCENT,
|
||||
hasAccount,
|
||||
REFRESH_AHEAD_MS,
|
||||
addAccount,
|
||||
addLoginAccount,
|
||||
@@ -533,6 +589,7 @@ module.exports = {
|
||||
poolStatus,
|
||||
pickAccount,
|
||||
forSpawn,
|
||||
switchHome,
|
||||
refreshQuotas,
|
||||
refreshLogins,
|
||||
__read: readAccount,
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -139,6 +140,7 @@ function removeAccount(name) {
|
||||
const n = validName(name);
|
||||
if (!readAccount(n)) throw err("ENOACCOUNT", `no account ${n} in the pool`);
|
||||
fs.rmSync(fileOf(n));
|
||||
require("./account-pins").dropAccount("codex", n);
|
||||
quotaCache = null;
|
||||
}
|
||||
|
||||
@@ -332,10 +334,15 @@ function syncBack() {
|
||||
|
||||
// ── 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();
|
||||
if (usable.length === 0) return null;
|
||||
let live = null;
|
||||
if (!member) {
|
||||
// A host pane's CODEX_HOME holds the host's own login.
|
||||
usable = usable.filter((a) => a.kind === "apikey");
|
||||
@@ -348,10 +355,15 @@ function pick(member) {
|
||||
} else {
|
||||
// One auth.json per home: while a pane of theirs runs on a pool
|
||||
// login, the next pane stays on that one.
|
||||
const live = usable.find((a) => a.kind === "chatgpt" && liveInHome(a.name, member));
|
||||
if (live) return live;
|
||||
live = usable.find((a) => a.kind === "chatgpt" && liveInHome(a.name, member)) || null;
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
const ranked = usable
|
||||
.map((a) => {
|
||||
@@ -395,8 +407,8 @@ function liveInHome(name, member) {
|
||||
}
|
||||
|
||||
/** `{name, kind}` of the account a new Codex pane would get, or null. */
|
||||
function pickAccount(member = null) {
|
||||
const a = pick(member);
|
||||
function pickAccount(member = null, ownerId = null) {
|
||||
const a = pick(member, ownerId);
|
||||
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`
|
||||
* (a member's launch file); `place()` runs just before the pane starts.
|
||||
*/
|
||||
function forSpawn(member = null) {
|
||||
const a = pick(member);
|
||||
function forSpawn(member = null, ownerId = null) {
|
||||
const a = pick(member, ownerId);
|
||||
if (!a) return null;
|
||||
if (a.kind === "apikey") {
|
||||
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 = {
|
||||
BLOCK_PERCENT,
|
||||
hasAccount,
|
||||
addAccount,
|
||||
removeAccount,
|
||||
poolStatus,
|
||||
|
||||
@@ -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 };
|
||||
@@ -7,7 +7,7 @@
|
||||
* workspace's own folder and not be or contain the OS home; an uploaded file
|
||||
* is stored by AgentHub under `<data dir>/workspace-context/<laneId>/` with a
|
||||
* name it chooses, so a request never names a path on disk. Only Claude
|
||||
* (`--add-dir`, `--append-system-prompt-file=`) and Codex (`--add-dir`,
|
||||
* (`--add-dir`, the instructions as a CLAUDE.md in a folder of their own) and Codex (`--add-dir`,
|
||||
* `-c developer_instructions=`) can take them; every other tool is reported
|
||||
* unsupported and its panes get nothing. The workspace OWNER's own agent
|
||||
* instructions (`user_prefs.instructions`, Settings → My agent instructions)
|
||||
@@ -184,7 +184,9 @@ function removeSource(laneId, sourceId) {
|
||||
function removeLaneContext(laneId) {
|
||||
const dir = contextDir(laneId);
|
||||
if (ownsFile(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
||||
fs.rmSync(path.join(getDataDir(), "workspace-prompts", `${Number(laneId)}.md`), { force: true });
|
||||
const prompts = path.join(getDataDir(), "workspace-prompts");
|
||||
fs.rmSync(path.join(prompts, String(Number(laneId))), { recursive: true, force: true });
|
||||
fs.rmSync(path.join(prompts, `${Number(laneId)}.md`), { force: true }); // before v2.8.9
|
||||
}
|
||||
|
||||
/** Instructions text as stored: a string of at most MAX_INSTRUCTIONS, blank ⇒ null. */
|
||||
@@ -286,18 +288,23 @@ function launchExtras(laneId, tool) {
|
||||
// A data dir that cannot be written costs the pane its instructions (all of
|
||||
// Claude's, Codex's past the inline budget), never its start — same as a
|
||||
// vanished source.
|
||||
// The file is a `CLAUDE.md` alone in a folder of its own: Claude reads it as
|
||||
// memory from that folder (`--add-dir` + CLAUDE_CODE_ADDITIONAL_DIRECTORIES_
|
||||
// CLAUDE_MD, pty-run.js), not as `--append-system-prompt-file` — a custom
|
||||
// system prompt is a launch flag Claude cannot carry over a restart, so it
|
||||
// refused `/tui` (switching renderer restarts the session) in every pane
|
||||
// that had instructions. Codex names the same file in its pointer line.
|
||||
try {
|
||||
const sub = `prompt-${Number(laneId)}`;
|
||||
if (member) {
|
||||
const file = require("./run-as").handoffWrite(
|
||||
member,
|
||||
`prompt-${Number(laneId)}.md`,
|
||||
systemPrompt
|
||||
);
|
||||
const runAs = require("./run-as");
|
||||
runAs.mkdirAs(member, path.join(runAs.handoffDir(member), sub));
|
||||
const file = runAs.handoffWrite(member, path.join(sub, "CLAUDE.md"), systemPrompt);
|
||||
return { addDirs, systemPrompt, systemPromptFile: file };
|
||||
}
|
||||
const dir = path.join(getDataDir(), "workspace-prompts");
|
||||
const dir = path.join(getDataDir(), "workspace-prompts", String(Number(laneId)));
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const systemPromptFile = path.join(dir, `${Number(laneId)}.md`);
|
||||
const systemPromptFile = path.join(dir, "CLAUDE.md");
|
||||
fs.writeFileSync(systemPromptFile, systemPrompt, { mode: 0o600 });
|
||||
return { addDirs, systemPrompt, systemPromptFile };
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* @file Oh My Pi (`omp`) sessions, subagents and token usage, read from the
|
||||
* session journals omp writes under `~/.omp/agent/sessions/<encoded cwd>/`.
|
||||
* This is how an omp session reaches `sessions` (as `tool = 'omp'`), its
|
||||
* tokens `token_usage` and its subagents `agents` — omp has no hooks.
|
||||
*
|
||||
* What a journal holds (omp 18.x, verified against files it wrote — not a
|
||||
* documented format):
|
||||
* - its file name is `<timestamp>_<session id>.jsonl`, and `omp --resume <id>`
|
||||
* takes that id back;
|
||||
* - line 1 is a fixed-width `{"type":"title", title}` slot omp rewrites in
|
||||
* place, line 2 the `{"type":"session", id, cwd, timestamp}` header;
|
||||
* - every assistant turn is a `{"type":"message"}` whose `message.usage` is
|
||||
* `{input, output, cacheRead, cacheWrite}` (input NOT including cache reads)
|
||||
* under `message.model`;
|
||||
* - a process that ends appends `{"type":"custom","customType":"session_exit"}`;
|
||||
* - a subagent the `task` tool spawns writes its own journal, in the same
|
||||
* format, under the folder named like the session file
|
||||
* (`<timestamp>_<session id>/<Agent name>.jsonl`, nested agents deeper).
|
||||
* Journals only grow (bar the title slot), so each is read from where the last
|
||||
* pass stopped; what it said so far is kept per file in `omp_sessions`, so a
|
||||
* session the user deleted stays deleted until omp writes to it again. A
|
||||
* session's tokens are the sum of its own journal and its subagents'.
|
||||
*
|
||||
* Status: completed once its process wrote `session_exit`, else active while
|
||||
* a journal changed in the last 2 minutes or a live dashboard pane holds the
|
||||
* session (an idle omp waiting for its user writes nothing), else completed.
|
||||
* S3: every member's own `~/.omp` is read too, only through `openSafeAsync`
|
||||
* (never a link, only a file that member owns), and only for a session whose
|
||||
* folder is that same member's.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { db, stmts } = require("../db");
|
||||
const { claimToolSessions } = require("./dashboard-runs");
|
||||
const pricingSync = require("./pricing-sync");
|
||||
const { readNew } = require("./jsonl-tail");
|
||||
|
||||
const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active
|
||||
const FUTURE_SLACK_MS = 1000;
|
||||
const SESSION_FILE = /^.+_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/;
|
||||
const SESSION_DIR = /^.+_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
|
||||
const HEAD_BYTES = 4096;
|
||||
const MAX_DEPTH = 4; // nested subagents
|
||||
const MAX_FILES = 20_000;
|
||||
|
||||
/** `~/.omp`, or `AGENTHUB_OMP_DIR`; read at call time. */
|
||||
function getOmpDir() {
|
||||
return process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
|
||||
}
|
||||
|
||||
/** A subagent's key: its path under the session folder, as one plain name. */
|
||||
const agentKey = (rel) =>
|
||||
rel
|
||||
.replace(/\.jsonl$/, "")
|
||||
.split(path.sep)
|
||||
.join(".")
|
||||
.replace(/[^\w.-]/g, "_")
|
||||
.replace(/^\.+/, "")
|
||||
.slice(0, 120);
|
||||
|
||||
/** The agents-table id of a subagent journal. */
|
||||
const agentRowId = (sessionId, key) => `omp-${sessionId}-${key}`;
|
||||
|
||||
/** Every journal: `{path, sessionId, agent ('' = the session's own), mtime, size}`. */
|
||||
function journalFiles() {
|
||||
const members = require("./run-as")
|
||||
.memberHomes()
|
||||
.map((h) => path.join(h, ".omp"));
|
||||
const out = [];
|
||||
for (const dir of [getOmpDir(), ...members]) {
|
||||
journalFilesIn(path.join(dir, "agent", "sessions"), out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function statFile(file) {
|
||||
try {
|
||||
const st = fs.lstatSync(file);
|
||||
return st.isFile() ? st : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function journalFilesIn(root, out) {
|
||||
let projects;
|
||||
try {
|
||||
projects = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const p of projects) {
|
||||
if (!p.isDirectory()) continue;
|
||||
const dir = path.join(root, p.name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
// The session's own journal before its subagents': a subagent row needs
|
||||
// its session row.
|
||||
entries.sort((a, b) => Number(b.isFile()) - Number(a.isFile()));
|
||||
for (const e of entries) {
|
||||
if (out.length >= MAX_FILES) return;
|
||||
const full = path.join(dir, e.name);
|
||||
let m;
|
||||
if (e.isFile() && (m = SESSION_FILE.exec(e.name))) {
|
||||
const st = statFile(full);
|
||||
if (st) {
|
||||
out.push({
|
||||
path: full,
|
||||
sessionId: m[1],
|
||||
agent: "",
|
||||
mtime: Math.floor(st.mtimeMs),
|
||||
size: st.size,
|
||||
});
|
||||
}
|
||||
} else if (e.isDirectory() && (m = SESSION_DIR.exec(e.name))) {
|
||||
subagentFiles(full, full, m[1], 0, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function subagentFiles(base, dir, sessionId, depth, out) {
|
||||
if (depth >= MAX_DEPTH) return;
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (out.length >= MAX_FILES) return;
|
||||
const full = path.join(dir, e.name);
|
||||
// Real folders and files only: a link is never followed.
|
||||
if (e.isDirectory()) subagentFiles(base, full, sessionId, depth + 1, out);
|
||||
else if (e.isFile() && e.name.endsWith(".jsonl")) {
|
||||
const st = statFile(full);
|
||||
const key = agentKey(path.relative(base, full));
|
||||
if (st && key) {
|
||||
out.push({
|
||||
path: full,
|
||||
sessionId,
|
||||
agent: key,
|
||||
mtime: Math.floor(st.mtimeMs),
|
||||
size: st.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A fresh per-journal state. */
|
||||
const emptyState = () => ({
|
||||
v: 1,
|
||||
cwd: null,
|
||||
startedAt: null,
|
||||
lastAt: null,
|
||||
model: null,
|
||||
firstPrompt: null,
|
||||
exited: false,
|
||||
usage: {},
|
||||
});
|
||||
|
||||
/** A message's text parts, joined. */
|
||||
function textOf(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.map((c) => (c && c.type === "text" && typeof c.text === "string" ? c.text : ""))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** A model id without its provider (`anthropic/claude-sonnet-4-5` → `claude-sonnet-4-5`). */
|
||||
const bareModel = (m) =>
|
||||
typeof m === "string" && m ? m.slice(m.lastIndexOf("/") + 1).slice(0, 128) : null;
|
||||
|
||||
const num = (v) => (Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
|
||||
|
||||
/** Fold one journal line into `state`. */
|
||||
function applyEntry(state, e) {
|
||||
if (!e || typeof e !== "object") return;
|
||||
const at = typeof e.timestamp === "string" ? e.timestamp : null;
|
||||
switch (e.type) {
|
||||
case "session":
|
||||
if (typeof e.cwd === "string" && path.isAbsolute(e.cwd)) state.cwd = e.cwd;
|
||||
if (at && !state.startedAt) state.startedAt = at;
|
||||
break;
|
||||
case "model_change":
|
||||
state.model = bareModel(e.model) || state.model;
|
||||
state.exited = false;
|
||||
break;
|
||||
case "message": {
|
||||
const m = e.message || {};
|
||||
state.exited = false;
|
||||
if (at) state.lastAt = at;
|
||||
if (m.role === "user" && !state.firstPrompt) {
|
||||
const text = textOf(m.content).trim();
|
||||
if (text && !text.startsWith("<system-reminder>")) state.firstPrompt = text.slice(0, 500);
|
||||
}
|
||||
if (m.role === "assistant" && m.usage && typeof m.usage === "object") {
|
||||
const model = bareModel(m.model) || state.model || "unknown";
|
||||
state.model = model;
|
||||
const u = (state.usage[model] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
||||
u.input += num(m.usage.input);
|
||||
u.output += num(m.usage.output);
|
||||
u.cacheRead += num(m.usage.cacheRead);
|
||||
u.cacheWrite += num(m.usage.cacheWrite);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "custom":
|
||||
if (e.customType === "session_exit") state.exited = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** The journal's title slot (line 1), or null. */
|
||||
async function readTitle(file) {
|
||||
let fh;
|
||||
try {
|
||||
fh = await require("./run-as").openSafeAsync(file);
|
||||
const buf = Buffer.alloc(HEAD_BYTES);
|
||||
const { bytesRead } = await fh.read(buf, 0, HEAD_BYTES, 0);
|
||||
const first = buf.toString("utf8", 0, bytesRead).split("\n")[0];
|
||||
const o = JSON.parse(first);
|
||||
return o?.type === "title" && typeof o.title === "string" && o.title.trim()
|
||||
? o.title.trim().slice(0, 200)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await fh?.close();
|
||||
}
|
||||
}
|
||||
|
||||
const getSeen = db.prepare("SELECT * FROM omp_sessions WHERE path = ?");
|
||||
const upsertSeen = db.prepare(
|
||||
`INSERT INTO omp_sessions (path, session_id, agent, size, mtime_ms, offset, state, title, updated_at)
|
||||
VALUES (@path, @sessionId, @agent, @size, @mtime, @offset, @state, @title,
|
||||
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
ON CONFLICT(path) DO UPDATE SET session_id = excluded.session_id, agent = excluded.agent,
|
||||
size = excluded.size, mtime_ms = excluded.mtime_ms, offset = excluded.offset,
|
||||
state = excluded.state, title = excluded.title, updated_at = excluded.updated_at`
|
||||
);
|
||||
const filesOf = db.prepare("SELECT * FROM omp_sessions WHERE session_id = ? ORDER BY agent");
|
||||
const activeOmp = db.prepare("SELECT id FROM sessions WHERE tool = 'omp' AND status = 'active'");
|
||||
const heldByPane = db.prepare(
|
||||
"SELECT 1 FROM dashboard_runs WHERE session_id = ? AND status IN ('running', 'spawning') LIMIT 1"
|
||||
);
|
||||
const insertSession = db.prepare(
|
||||
`INSERT INTO sessions (id, name, status, cwd, model, started_at, ended_at, updated_at, metadata, tool)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'omp')`
|
||||
);
|
||||
const updateSession = db.prepare(
|
||||
`UPDATE sessions SET name = ?, status = ?, model = COALESCE(?, model), ended_at = ?,
|
||||
updated_at = ?, cwd = COALESCE(cwd, ?), started_at = COALESCE(?, started_at)
|
||||
WHERE id = ? AND tool = 'omp'`
|
||||
);
|
||||
const clearUsage = db.prepare("DELETE FROM token_usage WHERE session_id = ?");
|
||||
const agentTimes = db.prepare(
|
||||
"UPDATE agents SET started_at = COALESCE(?, started_at), ended_at = ? WHERE id = ?"
|
||||
);
|
||||
|
||||
// A write "in the future" (a clock that runs ahead) is not proof of activity.
|
||||
const fresh = (mtime, now) => mtime - now < FUTURE_SLACK_MS && now - mtime < ACTIVE_MS;
|
||||
|
||||
const parseState = (row) => {
|
||||
try {
|
||||
return { ...emptyState(), ...JSON.parse(row.state) };
|
||||
} catch {
|
||||
return emptyState();
|
||||
}
|
||||
};
|
||||
|
||||
/** Read what one journal wrote since the last pass into its `omp_sessions` row. */
|
||||
async function readJournal(f) {
|
||||
const seen = getSeen.get(f.path);
|
||||
let state = seen && seen.session_id === f.sessionId ? parseState(seen) : emptyState();
|
||||
let offset = seen && seen.session_id === f.sessionId ? seen.offset : 0;
|
||||
// Smaller than what was read: rewritten, so read again from the top.
|
||||
if (f.size < offset) {
|
||||
state = emptyState();
|
||||
offset = 0;
|
||||
}
|
||||
const got = await readNew(f.path, offset, f.size);
|
||||
for (const line of got.lines) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
applyEntry(state, JSON.parse(line));
|
||||
} catch {
|
||||
/* a line that is not JSON: skipped */
|
||||
}
|
||||
}
|
||||
const title = f.agent ? null : await readTitle(f.path);
|
||||
upsertSeen.run({
|
||||
path: f.path,
|
||||
sessionId: f.sessionId,
|
||||
agent: f.agent,
|
||||
size: f.size,
|
||||
// A journal read only part-way is re-read next pass even if unchanged.
|
||||
mtime: got.offset < f.size ? -1 : f.mtime,
|
||||
offset: got.offset,
|
||||
state: JSON.stringify(state),
|
||||
title,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite one session's row, token usage and subagents from its journals'
|
||||
* rows. @returns the session's broadcast type, or null (nothing written).
|
||||
*/
|
||||
function writeSession(sessionId, now, broadcast) {
|
||||
const files = filesOf.all(sessionId);
|
||||
const main = files.find((r) => r.agent === "");
|
||||
if (!main) return null;
|
||||
const existing = stmts.getSession.get(sessionId);
|
||||
if (existing && existing.tool !== "omp") return null;
|
||||
const ms = parseState(main);
|
||||
const runAs = require("./run-as");
|
||||
// S3: a member's journal names only their own folder and session.
|
||||
if (!runAs.sameOwner(main.path, ms.cwd, existing?.cwd)) return null;
|
||||
const lastMtime = Math.max(...files.map((r) => r.mtime_ms));
|
||||
const lastWrite = new Date(lastMtime > 0 ? lastMtime : now).toISOString();
|
||||
const active = !ms.exited && (fresh(lastMtime, now) || !!heldByPane.get(sessionId));
|
||||
const status = active ? "active" : "completed";
|
||||
const endedAt = active ? null : lastWrite;
|
||||
const firstLine = ms.firstPrompt ? ms.firstPrompt.split("\n")[0].slice(0, 60) : null;
|
||||
const name = main.title || firstLine || existing?.name || `OMP ${sessionId.slice(0, 8)}`;
|
||||
const usage = {};
|
||||
for (const r of files) {
|
||||
for (const [m, u] of Object.entries(parseState(r).usage)) {
|
||||
const t = (usage[m] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
||||
t.input += u.input;
|
||||
t.output += u.output;
|
||||
t.cacheRead += u.cacheRead;
|
||||
t.cacheWrite += u.cacheWrite;
|
||||
}
|
||||
}
|
||||
const agentEvents = [];
|
||||
db.transaction(() => {
|
||||
if (existing) {
|
||||
updateSession.run(name, status, ms.model, endedAt, lastWrite, ms.cwd, ms.startedAt, sessionId);
|
||||
} else {
|
||||
insertSession.run(
|
||||
sessionId,
|
||||
name,
|
||||
status,
|
||||
ms.cwd,
|
||||
ms.model,
|
||||
ms.startedAt || lastWrite,
|
||||
endedAt,
|
||||
lastWrite,
|
||||
JSON.stringify({ source: "omp-journal" })
|
||||
);
|
||||
}
|
||||
claimToolSessions(sessionId); // its workspace, before the broadcast
|
||||
// Every usage row of an omp session is this module's: rewritten whole.
|
||||
clearUsage.run(sessionId);
|
||||
for (const [m, t] of Object.entries(usage)) {
|
||||
stmts.setTokenUsage.run(
|
||||
sessionId,
|
||||
m,
|
||||
"standard",
|
||||
"global",
|
||||
"standard",
|
||||
t.input,
|
||||
t.output,
|
||||
t.cacheRead,
|
||||
t.cacheWrite,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
}
|
||||
for (const r of files) {
|
||||
if (!r.agent || !runAs.sameOwner(main.path, r.path)) continue;
|
||||
const s = parseState(r);
|
||||
const id = agentRowId(sessionId, r.agent);
|
||||
const done = !active || s.exited || !fresh(r.mtime_ms, now);
|
||||
const agentStatus = done ? "completed" : "working";
|
||||
const ended = done ? new Date(r.mtime_ms > 0 ? r.mtime_ms : now).toISOString() : null;
|
||||
const label = r.agent.split(".").pop() || r.agent;
|
||||
const prior = stmts.getAgent.get(id);
|
||||
if (prior && prior.session_id !== sessionId) continue;
|
||||
if (!prior) {
|
||||
stmts.insertAgent.run(
|
||||
id,
|
||||
sessionId,
|
||||
label,
|
||||
"subagent",
|
||||
"task",
|
||||
agentStatus,
|
||||
s.firstPrompt,
|
||||
null,
|
||||
JSON.stringify({ source: "omp-journal", transcript: r.agent })
|
||||
);
|
||||
} else if (prior.status !== agentStatus || (s.firstPrompt && prior.task !== s.firstPrompt)) {
|
||||
stmts.updateAgent.run(null, agentStatus, s.firstPrompt, null, ended, null, id);
|
||||
} else continue;
|
||||
agentTimes.run(s.startedAt, ended, id);
|
||||
agentEvents.push([prior ? "agent_updated" : "agent_created", id]);
|
||||
}
|
||||
})();
|
||||
for (const [type, id] of agentEvents) broadcast(type, stmts.getAgent.get(id));
|
||||
return existing ? "session_updated" : "session_created";
|
||||
}
|
||||
|
||||
let running = null;
|
||||
// Fresh sessions the last pass left for a later one to settle (the poll keeps
|
||||
// its base pace while there are any).
|
||||
let lastPending = 0;
|
||||
|
||||
/**
|
||||
* One pass: every changed journal is read on from where it stopped, then each
|
||||
* session it touched — and each one still `active` — is rewritten.
|
||||
* @returns {Promise<{files: number, sessions: number}>}
|
||||
*/
|
||||
function ingestOmpUsage({ broadcast = () => {}, now = Date.now() } = {}) {
|
||||
if (running) return running;
|
||||
running = (async () => {
|
||||
let read = 0;
|
||||
let written = 0;
|
||||
const touched = new Set();
|
||||
for (const f of journalFiles()) {
|
||||
const seen = getSeen.get(f.path);
|
||||
if (seen && seen.mtime_ms === f.mtime && seen.size === f.size) continue;
|
||||
try {
|
||||
await readJournal(f);
|
||||
read += 1;
|
||||
touched.add(f.sessionId);
|
||||
} catch (err) {
|
||||
console.warn(`[omp-usage] skipped ${f.path}: ${err.message}`);
|
||||
}
|
||||
await new Promise((r) => setImmediate(r));
|
||||
}
|
||||
for (const { id } of activeOmp.all()) touched.add(id);
|
||||
let pending = 0;
|
||||
for (const id of touched) {
|
||||
try {
|
||||
const type = writeSession(id, now, broadcast);
|
||||
if (!type) continue;
|
||||
const s = stmts.getSession.get(id);
|
||||
if (s?.status === "active") pending += 1;
|
||||
written += 1;
|
||||
broadcast(type, s);
|
||||
} catch (err) {
|
||||
console.warn(`[omp-usage] session ${id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
lastPending = pending;
|
||||
return { files: read, sessions: written };
|
||||
})().finally(() => {
|
||||
running = null;
|
||||
});
|
||||
return running;
|
||||
}
|
||||
|
||||
/**
|
||||
* The journal behind a session's transcript: its own (`agent` '' / "main"),
|
||||
* or one subagent's by key. Null when unknown.
|
||||
*/
|
||||
function journalPath(sessionId, agent = "") {
|
||||
const key = agent === "main" ? "" : agent || "";
|
||||
const row = db
|
||||
.prepare("SELECT path FROM omp_sessions WHERE session_id = ? AND agent = ?")
|
||||
.get(sessionId, key);
|
||||
return row?.path || null;
|
||||
}
|
||||
|
||||
/** A session's subagent journals: `[{agent, name, task, startedAt}]`. */
|
||||
function subagentJournals(sessionId) {
|
||||
return filesOf
|
||||
.all(sessionId)
|
||||
.filter((r) => r.agent)
|
||||
.map((r) => {
|
||||
const s = parseState(r);
|
||||
return {
|
||||
agent: r.agent,
|
||||
name: r.agent.split(".").pop() || r.agent,
|
||||
task: s.firstPrompt,
|
||||
startedAt: s.startedAt,
|
||||
rowId: agentRowId(sessionId, r.agent),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* First pass at once (in the background), then every DASHBOARD_OMP_POLL_MS
|
||||
* (default 15 s; 0 disables) — slowed to once a minute after a pass that read
|
||||
* nothing, wrote nothing and left no active session to settle.
|
||||
*/
|
||||
function startOmpUsagePoll(broadcast) {
|
||||
const POLL_MS = process.env.DASHBOARD_OMP_POLL_MS
|
||||
? Number(process.env.DASHBOARD_OMP_POLL_MS)
|
||||
: 15_000;
|
||||
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
|
||||
require("./backoff-poll").startBackoffPoll(
|
||||
() =>
|
||||
ingestOmpUsage({ broadcast }).then((r) => {
|
||||
if (r.sessions > 0 && pricingSync.syncEnabled()) void pricingSync.syncPricing(db);
|
||||
return r.files + r.sessions + lastPending > 0;
|
||||
}),
|
||||
{ baseMs: POLL_MS }
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getOmpDir,
|
||||
agentKey,
|
||||
applyEntry,
|
||||
emptyState,
|
||||
ingestOmpUsage,
|
||||
journalPath,
|
||||
subagentJournals,
|
||||
startOmpUsagePoll,
|
||||
};
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* @file OpenCode (`opencode`) sessions, subagents and token usage, read from
|
||||
* OpenCode's own SQLite store (`~/.local/share/opencode/opencode.db`, under
|
||||
* `XDG_DATA_HOME` when set). This is how an OpenCode session reaches
|
||||
* `sessions` (as `tool = 'opencode'`), its tokens `token_usage` and its
|
||||
* subagents `agents` — OpenCode has no hooks AgentHub installs.
|
||||
*
|
||||
* What the store holds (opencode 1.19, verified against a file it wrote — not
|
||||
* a documented format):
|
||||
* - `session(id, parent_id, directory, title, time_created, time_updated,
|
||||
* time_archived)` — a session the `task` tool spawned has `parent_id` set,
|
||||
* and is shown as a subagent of its root session;
|
||||
* - `message(session_id, data)` — an assistant message's JSON carries
|
||||
* `modelID` and `tokens: {input, output, reasoning, cache: {read, write}}`
|
||||
* (input NOT including cache reads; reasoning is counted as output here);
|
||||
* - `part(message_id, session_id, data)` — the message's text, reasoning and
|
||||
* tool parts, read only for a transcript.
|
||||
* The file is re-read when its mtime (or its non-empty `-wal`'s) changes; per
|
||||
* root session the newest `time_updated` seen lives in `opencode_sessions`,
|
||||
* so a session the user deleted stays deleted until OpenCode writes to it
|
||||
* again. Status: active while its tree was updated in the last 2 minutes or a
|
||||
* live dashboard pane holds it, else completed. S3: a member's store (and its
|
||||
* WAL) is read through the guard into a private copy, never opened in place.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { db, stmts, Database } = require("../db");
|
||||
const { claimToolSessions } = require("./dashboard-runs");
|
||||
const pricingSync = require("./pricing-sync");
|
||||
|
||||
const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active
|
||||
const FUTURE_SLACK_MS = 1000;
|
||||
const SESSION_ID = /^[A-Za-z0-9_-]{8,120}$/;
|
||||
const MAX_COPY = 256 << 20;
|
||||
|
||||
/** OpenCode's data dir: `AGENTHUB_OPENCODE_DIR`, `$XDG_DATA_HOME/opencode`, else `~/.local/share/opencode`. */
|
||||
function getOpencodeDir() {
|
||||
if (process.env.AGENTHUB_OPENCODE_DIR) return process.env.AGENTHUB_OPENCODE_DIR;
|
||||
const xdg = process.env.XDG_DATA_HOME;
|
||||
return path.join(xdg && path.isAbsolute(xdg) ? xdg : path.join(os.homedir(), ".local", "share"), "opencode");
|
||||
}
|
||||
|
||||
/** Every store: `{path, mtime}` — this account's, then every member's. */
|
||||
function storeFiles() {
|
||||
const members = require("./run-as")
|
||||
.memberHomes()
|
||||
.map((h) => path.join(h, ".local", "share", "opencode"));
|
||||
const out = [];
|
||||
for (const dir of [getOpencodeDir(), ...members]) {
|
||||
const file = path.join(dir, "opencode.db");
|
||||
try {
|
||||
const st = fs.lstatSync(file);
|
||||
if (!st.isFile()) continue;
|
||||
let mtime = st.mtimeMs;
|
||||
try {
|
||||
const wal = fs.lstatSync(`${file}-wal`);
|
||||
if (wal.isFile() && wal.size > 0) mtime = Math.max(mtime, wal.mtimeMs);
|
||||
} catch {
|
||||
/* no WAL right now */
|
||||
}
|
||||
out.push({ path: file, mtime: Math.floor(mtime) });
|
||||
} catch {
|
||||
/* no store */
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn(conn)` on a store, read-only. A member's store (S3) and its WAL are
|
||||
* copied through the guard first: SQLite opens by NAME, and a member's FIFO
|
||||
* standing in for the `-wal` would block this thread.
|
||||
*/
|
||||
function withStore(file, fn) {
|
||||
const { ownerForRead, readFileSafe } = require("./run-as");
|
||||
let tmp = null;
|
||||
try {
|
||||
let open = file;
|
||||
if (require("./linux-accounts").s3Enabled() && ownerForRead(file)) {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-opencode-"));
|
||||
open = path.join(tmp, "opencode.db");
|
||||
fs.writeFileSync(open, readFileSafe(file, null, { max: MAX_COPY }));
|
||||
try {
|
||||
fs.writeFileSync(`${open}-wal`, readFileSafe(`${file}-wal`, null, { max: MAX_COPY }));
|
||||
} catch {
|
||||
/* no WAL (or not theirs): what is in the main file */
|
||||
}
|
||||
}
|
||||
const conn = new Database(open, { readonly: !tmp, fileMustExist: true });
|
||||
try {
|
||||
return fn(conn);
|
||||
} finally {
|
||||
conn.close();
|
||||
}
|
||||
} finally {
|
||||
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** A session's model column (`{"id": …}` JSON, or a plain id), or null. */
|
||||
function modelOf(raw) {
|
||||
if (typeof raw !== "string" || !raw) return null;
|
||||
try {
|
||||
const o = JSON.parse(raw);
|
||||
return typeof o?.id === "string" ? o.id.slice(0, 128) : null;
|
||||
} catch {
|
||||
return raw.slice(0, 128);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a pass needs from one store: `[{root, children, usage, updated}]`
|
||||
* per root session — `usage` per model over the root and every descendant.
|
||||
*/
|
||||
function readStore(file) {
|
||||
return withStore(file, (conn) => {
|
||||
const rows = conn
|
||||
.prepare(
|
||||
`SELECT id, parent_id, directory, title, time_created, time_updated, time_archived, model
|
||||
FROM session`
|
||||
)
|
||||
.all();
|
||||
const byId = new Map(rows.map((r) => [r.id, r]));
|
||||
const rootOf = (r) => {
|
||||
let cur = r;
|
||||
for (let i = 0; i < 32 && cur.parent_id && byId.has(cur.parent_id); i++) {
|
||||
cur = byId.get(cur.parent_id);
|
||||
}
|
||||
return cur;
|
||||
};
|
||||
const trees = new Map();
|
||||
for (const r of rows) {
|
||||
if (!SESSION_ID.test(r.id)) continue;
|
||||
const root = rootOf(r);
|
||||
if (!SESSION_ID.test(root.id)) continue;
|
||||
const t = trees.get(root.id) || { root, children: [], usage: {}, updated: 0, model: null };
|
||||
if (r !== root) t.children.push(r);
|
||||
t.updated = Math.max(t.updated, Number(r.time_updated) || 0);
|
||||
trees.set(root.id, t);
|
||||
}
|
||||
const sums = conn
|
||||
.prepare(
|
||||
`SELECT session_id,
|
||||
json_extract(data, '$.modelID') AS model,
|
||||
SUM(COALESCE(json_extract(data, '$.tokens.input'), 0)) AS input,
|
||||
SUM(COALESCE(json_extract(data, '$.tokens.output'), 0)
|
||||
+ COALESCE(json_extract(data, '$.tokens.reasoning'), 0)) AS output,
|
||||
SUM(COALESCE(json_extract(data, '$.tokens.cache.read'), 0)) AS cache_read,
|
||||
SUM(COALESCE(json_extract(data, '$.tokens.cache.write'), 0)) AS cache_write,
|
||||
MAX(time_created) AS last
|
||||
FROM message
|
||||
WHERE json_extract(data, '$.role') = 'assistant'
|
||||
GROUP BY session_id, model`
|
||||
)
|
||||
.all();
|
||||
for (const s of sums) {
|
||||
const r = byId.get(s.session_id);
|
||||
const t = r && trees.get(rootOf(r).id);
|
||||
if (!t) continue;
|
||||
const model = typeof s.model === "string" && s.model ? s.model.slice(0, 128) : "unknown";
|
||||
const u = (t.usage[model] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
||||
u.input += Number(s.input) || 0;
|
||||
u.output += Number(s.output) || 0;
|
||||
u.cacheRead += Number(s.cache_read) || 0;
|
||||
u.cacheWrite += Number(s.cache_write) || 0;
|
||||
if (r === t.root && (!t.lastAt || s.last > t.lastAt)) {
|
||||
t.lastAt = s.last;
|
||||
t.model = model;
|
||||
}
|
||||
}
|
||||
return [...trees.values()];
|
||||
});
|
||||
}
|
||||
|
||||
const getStore = db.prepare("SELECT * FROM opencode_stores WHERE path = ?");
|
||||
const upsertStore = db.prepare(
|
||||
`INSERT INTO opencode_stores (path, mtime_ms, updated_at)
|
||||
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
ON CONFLICT(path) DO UPDATE SET mtime_ms = excluded.mtime_ms, updated_at = excluded.updated_at`
|
||||
);
|
||||
const getTracked = db.prepare("SELECT * FROM opencode_sessions WHERE session_id = ?");
|
||||
const upsertTracked = db.prepare(
|
||||
`INSERT INTO opencode_sessions (session_id, store, time_updated, updated_at)
|
||||
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
ON CONFLICT(session_id) DO UPDATE SET store = excluded.store,
|
||||
time_updated = excluded.time_updated, updated_at = excluded.updated_at`
|
||||
);
|
||||
const activeOpencode = db.prepare(
|
||||
"SELECT s.id, t.time_updated FROM sessions s JOIN opencode_sessions t ON t.session_id = s.id WHERE s.tool = 'opencode' AND s.status = 'active'"
|
||||
);
|
||||
const heldByPane = db.prepare(
|
||||
"SELECT 1 FROM dashboard_runs WHERE session_id = ? AND status IN ('running', 'spawning') LIMIT 1"
|
||||
);
|
||||
const insertSession = db.prepare(
|
||||
`INSERT INTO sessions (id, name, status, cwd, model, started_at, ended_at, updated_at, metadata, tool)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'opencode')`
|
||||
);
|
||||
const updateSession = db.prepare(
|
||||
`UPDATE sessions SET name = ?, status = ?, model = COALESCE(?, model), ended_at = ?,
|
||||
updated_at = ?, cwd = COALESCE(cwd, ?), started_at = COALESCE(?, started_at)
|
||||
WHERE id = ? AND tool = 'opencode'`
|
||||
);
|
||||
const settle = db.prepare(
|
||||
"UPDATE sessions SET status = 'completed', ended_at = ? WHERE id = ? AND tool = 'opencode'"
|
||||
);
|
||||
const clearUsage = db.prepare("DELETE FROM token_usage WHERE session_id = ?");
|
||||
const agentTimes = db.prepare(
|
||||
"UPDATE agents SET started_at = COALESCE(?, started_at), ended_at = ? WHERE id = ?"
|
||||
);
|
||||
|
||||
const fresh = (t, now) => t - now < FUTURE_SLACK_MS && now - t < ACTIVE_MS;
|
||||
const iso = (t) => (Number.isFinite(Number(t)) && Number(t) > 0 ? new Date(Number(t)).toISOString() : null);
|
||||
|
||||
/** Write one root session's row, usage and subagents. @returns its broadcast type, or null. */
|
||||
function writeSession(store, t, now, broadcast) {
|
||||
const r = t.root;
|
||||
const existing = stmts.getSession.get(r.id);
|
||||
if (existing && existing.tool !== "opencode") return null;
|
||||
const cwd = typeof r.directory === "string" && path.isAbsolute(r.directory) ? r.directory : null;
|
||||
const runAs = require("./run-as");
|
||||
// S3: a member's store names only their own folder and session.
|
||||
if (!runAs.sameOwner(store, cwd, existing?.cwd)) return null;
|
||||
const lastWrite = iso(t.updated) || new Date(now).toISOString();
|
||||
const active = !r.time_archived && (fresh(t.updated, now) || !!heldByPane.get(r.id));
|
||||
const status = active ? "active" : "completed";
|
||||
const endedAt = active ? null : lastWrite;
|
||||
const name =
|
||||
(typeof r.title === "string" && r.title.trim().slice(0, 200)) ||
|
||||
existing?.name ||
|
||||
`OpenCode ${r.id.slice(4, 12)}`;
|
||||
const model = t.model || modelOf(r.model);
|
||||
const agentEvents = [];
|
||||
db.transaction(() => {
|
||||
if (existing) {
|
||||
updateSession.run(name, status, model, endedAt, lastWrite, cwd, iso(r.time_created), r.id);
|
||||
} else {
|
||||
insertSession.run(
|
||||
r.id,
|
||||
name,
|
||||
status,
|
||||
cwd,
|
||||
model,
|
||||
iso(r.time_created) || lastWrite,
|
||||
endedAt,
|
||||
lastWrite,
|
||||
JSON.stringify({ source: "opencode-store" })
|
||||
);
|
||||
}
|
||||
claimToolSessions(r.id);
|
||||
clearUsage.run(r.id);
|
||||
for (const [m, u] of Object.entries(t.usage)) {
|
||||
stmts.setTokenUsage.run(
|
||||
r.id,
|
||||
m,
|
||||
"standard",
|
||||
"global",
|
||||
"standard",
|
||||
u.input,
|
||||
u.output,
|
||||
u.cacheRead,
|
||||
u.cacheWrite,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
}
|
||||
for (const c of t.children) {
|
||||
const id = `opencode-${c.id}`;
|
||||
const done = !active || !fresh(Number(c.time_updated) || 0, now);
|
||||
const agentStatus = done ? "completed" : "working";
|
||||
const ended = done ? iso(c.time_updated) : null;
|
||||
const label = (typeof c.title === "string" && c.title.trim().slice(0, 120)) || c.id;
|
||||
const prior = stmts.getAgent.get(id);
|
||||
if (prior && prior.session_id !== r.id) continue;
|
||||
if (!prior) {
|
||||
stmts.insertAgent.run(
|
||||
id,
|
||||
r.id,
|
||||
label,
|
||||
"subagent",
|
||||
"task",
|
||||
agentStatus,
|
||||
label,
|
||||
null,
|
||||
JSON.stringify({ source: "opencode-store", transcript: c.id })
|
||||
);
|
||||
} else if (prior.status !== agentStatus || prior.name !== label) {
|
||||
stmts.updateAgent.run(label, agentStatus, label, null, ended, null, id);
|
||||
} else continue;
|
||||
agentTimes.run(iso(c.time_created), ended, id);
|
||||
agentEvents.push([prior ? "agent_updated" : "agent_created", id]);
|
||||
}
|
||||
upsertTracked.run(r.id, store, t.updated);
|
||||
})();
|
||||
for (const [type, id] of agentEvents) broadcast(type, stmts.getAgent.get(id));
|
||||
return existing ? "session_updated" : "session_created";
|
||||
}
|
||||
|
||||
let running = null;
|
||||
let lastPending = 0;
|
||||
|
||||
/**
|
||||
* One pass: every store whose file changed is read, and each root session
|
||||
* updated since it was last written is rewritten; an `active` one that went
|
||||
* quiet is settled.
|
||||
* @returns {Promise<{files: number, sessions: number}>}
|
||||
*/
|
||||
function ingestOpencodeUsage({ broadcast = () => {}, now = Date.now() } = {}) {
|
||||
if (running) return running;
|
||||
running = (async () => {
|
||||
let read = 0;
|
||||
let written = 0;
|
||||
const done = new Set();
|
||||
for (const f of storeFiles()) {
|
||||
const seen = getStore.get(f.path);
|
||||
if (seen && seen.mtime_ms === f.mtime) continue;
|
||||
let trees;
|
||||
try {
|
||||
trees = readStore(f.path);
|
||||
} catch (err) {
|
||||
if (getStore.get(f.path)?.mtime_ms !== f.mtime) {
|
||||
console.warn(`[opencode-usage] skipped ${f.path}: ${err.message}`);
|
||||
}
|
||||
upsertStore.run(f.path, f.mtime);
|
||||
continue;
|
||||
}
|
||||
read += 1;
|
||||
for (const t of trees) {
|
||||
const tracked = getTracked.get(t.root.id);
|
||||
const changed = !tracked || tracked.time_updated !== t.updated;
|
||||
const s = stmts.getSession.get(t.root.id);
|
||||
// Unchanged and deleted by the user: stays deleted.
|
||||
if (!changed && (!s || s.status !== "active")) continue;
|
||||
try {
|
||||
const type = writeSession(f.path, t, now, broadcast);
|
||||
if (!type) continue;
|
||||
done.add(t.root.id);
|
||||
written += 1;
|
||||
broadcast(type, stmts.getSession.get(t.root.id));
|
||||
} catch (err) {
|
||||
console.warn(`[opencode-usage] session ${t.root.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
upsertStore.run(f.path, f.mtime);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
}
|
||||
// An active session in a store that did not change: settled once quiet.
|
||||
let pending = 0;
|
||||
for (const { id, time_updated: updated } of activeOpencode.all()) {
|
||||
if (done.has(id)) {
|
||||
pending += 1;
|
||||
continue;
|
||||
}
|
||||
if (fresh(updated, now) || heldByPane.get(id)) {
|
||||
pending += 1;
|
||||
continue;
|
||||
}
|
||||
settle.run(iso(updated) || new Date(now).toISOString(), id);
|
||||
for (const a of stmts.listAgentsBySession.all(id)) {
|
||||
if (a.status === "completed" || a.status === "error") continue;
|
||||
stmts.updateAgent.run(null, "completed", null, null, iso(updated), null, a.id);
|
||||
broadcast("agent_updated", stmts.getAgent.get(a.id));
|
||||
}
|
||||
written += 1;
|
||||
broadcast("session_updated", stmts.getSession.get(id));
|
||||
}
|
||||
lastPending = pending;
|
||||
return { files: read, sessions: written };
|
||||
})().finally(() => {
|
||||
running = null;
|
||||
});
|
||||
return running;
|
||||
}
|
||||
|
||||
/** A tool part's input/output, kept small. */
|
||||
const short = (v, max = 10240) => {
|
||||
const s = typeof v === "string" ? v : v === undefined ? "" : JSON.stringify(v);
|
||||
return s.length > max ? `${s.slice(0, max)}[truncated]` : s;
|
||||
};
|
||||
const TEXT_MAX = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* One OpenCode session's conversation (`sessionId`, or one of its subagent
|
||||
* sessions by `childId`), in the Claude transcript shape the client renders:
|
||||
* `[{type, sender, timestamp, content}]`, oldest first. Null when unknown.
|
||||
*/
|
||||
function transcriptMessages(sessionId, childId = null) {
|
||||
const tracked = getTracked.get(sessionId);
|
||||
if (!tracked) return null;
|
||||
const target = childId || sessionId;
|
||||
if (!SESSION_ID.test(target)) return null;
|
||||
return withStore(tracked.store, (conn) => {
|
||||
if (childId) {
|
||||
// Only a session of this tree.
|
||||
let cur = conn.prepare("SELECT id, parent_id FROM session WHERE id = ?").get(childId);
|
||||
for (let i = 0; i < 32 && cur && cur.id !== sessionId; i++) {
|
||||
cur = cur.parent_id
|
||||
? conn.prepare("SELECT id, parent_id FROM session WHERE id = ?").get(cur.parent_id)
|
||||
: null;
|
||||
}
|
||||
if (!cur) return null;
|
||||
}
|
||||
const messages = conn
|
||||
.prepare("SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created, id")
|
||||
.all(target);
|
||||
const parts = conn
|
||||
.prepare("SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created, id")
|
||||
.all(target);
|
||||
const byMessage = new Map();
|
||||
for (const p of parts) {
|
||||
let d;
|
||||
try {
|
||||
d = JSON.parse(p.data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
(byMessage.get(p.message_id) || byMessage.set(p.message_id, []).get(p.message_id)).push(d);
|
||||
}
|
||||
const out = [];
|
||||
for (const m of messages) {
|
||||
let info;
|
||||
try {
|
||||
info = JSON.parse(m.data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const at = iso(m.time_created);
|
||||
const role = info.role === "user" ? "user" : info.role === "assistant" ? "assistant" : null;
|
||||
if (!role) continue;
|
||||
const content = [];
|
||||
const results = [];
|
||||
for (const p of byMessage.get(m.id) || []) {
|
||||
if (p.type === "text" && typeof p.text === "string" && p.text.trim() && !p.synthetic) {
|
||||
content.push({ type: "text", text: short(p.text, TEXT_MAX) });
|
||||
} else if (p.type === "reasoning" && typeof p.text === "string" && p.text.trim()) {
|
||||
content.push({ type: "thinking", text: short(p.text) });
|
||||
} else if (p.type === "tool") {
|
||||
const st = p.state || {};
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
name: p.tool || "unknown",
|
||||
id: p.callID || null,
|
||||
input: st.input && typeof st.input === "object" ? st.input : {},
|
||||
});
|
||||
if (st.status === "completed" || st.status === "error") {
|
||||
results.push({
|
||||
type: "tool_result",
|
||||
id: p.callID || null,
|
||||
output: short(st.status === "error" ? st.error : st.output),
|
||||
is_error: st.status === "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (content.length) {
|
||||
const sender = role === "user" ? (childId && !out.length ? "orchestrator" : "user") : "assistant";
|
||||
const msg = { type: role, sender, timestamp: at, content };
|
||||
if (role === "assistant" && info.modelID) msg.model = info.modelID;
|
||||
out.push(msg);
|
||||
}
|
||||
if (results.length) out.push({ type: "user", sender: "tool", timestamp: at, content: results });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
/** A root session's subagent sessions: `[{id, name, rowId}]`. */
|
||||
function subagentSessions(sessionId) {
|
||||
return stmts
|
||||
.listAgentsBySession.all(sessionId)
|
||||
.filter((a) => a.id.startsWith("opencode-"))
|
||||
.map((a) => ({ id: a.id.slice("opencode-".length), name: a.name, rowId: a.id }));
|
||||
}
|
||||
|
||||
/**
|
||||
* First pass at once, then every DASHBOARD_OPENCODE_POLL_MS (default 15 s; 0
|
||||
* disables) — once a minute after a pass that found nothing to do.
|
||||
*/
|
||||
function startOpencodeUsagePoll(broadcast) {
|
||||
const POLL_MS = process.env.DASHBOARD_OPENCODE_POLL_MS
|
||||
? Number(process.env.DASHBOARD_OPENCODE_POLL_MS)
|
||||
: 15_000;
|
||||
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
|
||||
require("./backoff-poll").startBackoffPoll(
|
||||
() =>
|
||||
ingestOpencodeUsage({ broadcast }).then((r) => {
|
||||
if (r.sessions > 0 && pricingSync.syncEnabled()) void pricingSync.syncPricing(db);
|
||||
return r.files + r.sessions + lastPending > 0;
|
||||
}),
|
||||
{ baseMs: POLL_MS }
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getOpencodeDir,
|
||||
ingestOpencodeUsage,
|
||||
transcriptMessages,
|
||||
subagentSessions,
|
||||
startOpencodeUsagePoll,
|
||||
};
|
||||
@@ -12,6 +12,8 @@
|
||||
* the driver's size and told so (`{type:"size", cols, rows}`) so its xterm
|
||||
* renders the same grid, scaled to fit, instead of each browser resizing tmux
|
||||
* under the others. A driver that leaves sends `{type:"lead"}` to the rest.
|
||||
* A keystroke that follows a mouse report first takes the pane out of tmux's
|
||||
* copy-mode (the wheel's scrollback), so typing never lands in it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -73,7 +75,16 @@ function attachBinary(platform = process.platform) {
|
||||
// the paste in that issue's measurements.
|
||||
const PASTE_HOLD_MS = 700;
|
||||
|
||||
// runId -> { driver, viewers: Set<{ ws, pty, cols, rows }> }
|
||||
// Input that is not typing: a mouse report (SGR `ESC[<b;x;yM`, X10 `ESC[M` +
|
||||
// three bytes) or a focus in/out. A wheel report is what puts a pane into
|
||||
// tmux's copy-mode, where every typed key is taken as a scrollback command —
|
||||
// the pane looked frozen. So once a mouse report has passed, the next real
|
||||
// keystroke first leaves copy-mode (`tmux.leaveCopyMode`), then is written.
|
||||
const NOT_TYPING_RE = /\x1b\[<\d+;\d+;\d+[Mm]|\x1b\[M[\s\S]{3}|\x1b\[[IO]/g;
|
||||
|
||||
// runId -> { driver, viewers: Set<{ ws, pty, cols, rows }>, scrolled }
|
||||
// (`scrolled`: a mouse report reached the pane since the last keystroke — per
|
||||
// pane, since one browser may scroll and another type)
|
||||
const groups = new Map();
|
||||
|
||||
const tell = (v, msg) => {
|
||||
@@ -211,6 +222,16 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
|
||||
/* the attach exited while its input was held — nothing to write to */
|
||||
}
|
||||
};
|
||||
// Keystrokes after a scroll leave copy-mode first; see NOT_TYPING_RE.
|
||||
function input(text) {
|
||||
if (text.replace(NOT_TYPING_RE, "") === "") {
|
||||
if (text.includes("\x1b[M") || text.includes("\x1b[<")) group.scrolled = true;
|
||||
} else if (group.scrolled) {
|
||||
group.scrolled = false;
|
||||
tmux.leaveCopyMode(runId);
|
||||
}
|
||||
write(text);
|
||||
}
|
||||
function write(text) {
|
||||
if (me.exited) return; // the attach is gone: nothing reads this pipe
|
||||
const wait = holdUntil - Date.now();
|
||||
@@ -248,7 +269,7 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
|
||||
const binary = typeof isBinary === "boolean" ? isBinary : !!(isBinary && isBinary.binary);
|
||||
const text = data.toString("utf8");
|
||||
if (binary) {
|
||||
write(text);
|
||||
input(text);
|
||||
return;
|
||||
}
|
||||
// The browser's WebSocket API sends a JS string as a text frame, and
|
||||
@@ -262,7 +283,7 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
|
||||
try {
|
||||
msg = JSON.parse(text);
|
||||
} catch {
|
||||
write(text);
|
||||
input(text);
|
||||
return;
|
||||
}
|
||||
if (msg && msg.type === "resize" && Number.isFinite(msg.cols) && Number.isFinite(msg.rows)) {
|
||||
@@ -274,7 +295,7 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
write(text);
|
||||
input(text);
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
|
||||
+28
-14
@@ -202,7 +202,10 @@ function buildClaudeArgv({
|
||||
// `--add-dir` is VARIADIC (`<directories...>`): it swallows every following
|
||||
// argument up to the next option, so it goes first, where `--permission-mode`
|
||||
// always ends it — after the prompt it would eat the prompt as a directory.
|
||||
if (addDirs.length) argv.push("--add-dir", ...addDirs);
|
||||
// The instructions' own folder rides along: its CLAUDE.md is read as memory
|
||||
// (see lane-sources.js launchExtras), so `/tui` can still restart the pane.
|
||||
const dirs = systemPromptFile ? [...addDirs, path.dirname(systemPromptFile)] : addDirs;
|
||||
if (dirs.length) argv.push("--add-dir", ...dirs);
|
||||
argv.push("--permission-mode", permissionMode || "auto");
|
||||
// Inside a pane Claude Code sees $TMUX and opens agent-team teammates with
|
||||
// `split-window -P -F '#{pane_id}'`. psmux ignores -F, so the id it reads
|
||||
@@ -220,9 +223,6 @@ function buildClaudeArgv({
|
||||
// `=` form: `-w [name]` takes an OPTIONAL value and would otherwise swallow
|
||||
// the prompt as the worktree's name.
|
||||
if (worktreeName) argv.push(`--worktree=${worktreeName}`);
|
||||
// From a file: the instructions can be long, and the whole argv has to fit
|
||||
// in one tmux message. `=` form, so the path is never read as an option.
|
||||
if (systemPromptFile) argv.push(`--append-system-prompt-file=${systemPromptFile}`);
|
||||
// Puts "bypass permissions" INTO the pane's shift+tab cycle without turning
|
||||
// it on: `claude --help` — "Enable bypassing all permission checks as an
|
||||
// option, without it being enabled by default." Without it the cycle is only
|
||||
@@ -295,16 +295,18 @@ function buildCodexArgv({
|
||||
|
||||
/**
|
||||
* Any catalog tool without a hand-written launcher: `[bin, ...args, ...resume?,
|
||||
* ...bypass?, ...prompt]` (`[flag, model]` right after the fixed args when the
|
||||
* ...bypass?, ...prompt]` (`...resumeById, <id>` in place of `resume` for a
|
||||
* named session; `[flag, model]` right after the fixed args when the
|
||||
* catalog declares a model flag). Every piece but the prompt and the
|
||||
* validated model comes from the catalog
|
||||
* file, and the prompt is one argv element — or, for `after-start`, nothing
|
||||
* here: spawnRun pastes it into the pane once the CLI is up.
|
||||
*/
|
||||
function buildCatalogArgv(entry, bin, { model, resumeLast, bypass, initialPrompt }) {
|
||||
function buildCatalogArgv(entry, bin, { model, resumeSessionId, resumeLast, bypass, initialPrompt }) {
|
||||
const argv = [bin, ...entry.args];
|
||||
if (model && entry.model) argv.push(entry.model.flag, model);
|
||||
if (resumeLast && entry.resume) argv.push(...entry.resume);
|
||||
if (resumeSessionId && entry.resumeById) argv.push(...entry.resumeById, resumeSessionId);
|
||||
else if (resumeLast && entry.resume) argv.push(...entry.resume);
|
||||
if (bypass && entry.bypass) argv.push(...entry.bypass);
|
||||
const p = entry.prompt;
|
||||
if (initialPrompt && p.mode === "flag") argv.push(p.flag, promptArg(initialPrompt));
|
||||
@@ -437,15 +439,21 @@ function spawnRun(args) {
|
||||
}
|
||||
if (
|
||||
resumeSessionId != null &&
|
||||
(typeof resumeSessionId !== "string" || !/^[A-Za-z0-9-]{8,}$/.test(resumeSessionId))
|
||||
(typeof resumeSessionId !== "string" || !/^[A-Za-z0-9_-]{8,}$/.test(resumeSessionId))
|
||||
) {
|
||||
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
|
||||
}
|
||||
// Only the hand-written launchers can resume a NAMED session; a catalog
|
||||
// tool would silently start fresh under a "resume".
|
||||
if (resumeSessionId && !entry.launcher) {
|
||||
// Only the hand-written launchers and a catalog tool that declares
|
||||
// `resumeById` can resume a NAMED session; any other would silently start
|
||||
// fresh under a "resume".
|
||||
if (resumeSessionId && !entry.launcher && !entry.resumeById) {
|
||||
throw makeErr("EUNSUPPORTED", `${tool} cannot resume a session by id`);
|
||||
}
|
||||
// A catalog tool takes the id as an argv element of its own: never one that
|
||||
// could read as a flag.
|
||||
if (resumeSessionId && !entry.launcher && !/^[A-Za-z0-9]/.test(resumeSessionId)) {
|
||||
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
|
||||
}
|
||||
if (worktree === true && entry.launcher !== "claude") {
|
||||
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
|
||||
}
|
||||
@@ -461,19 +469,20 @@ function spawnRun(args) {
|
||||
assertCwdAccount(laneId, cwd); // ECWDACCOUNT
|
||||
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
|
||||
// A Claude session on the vendor's own API runs on the least-loaded pool
|
||||
// account, if the admin added any (claude-pool.js); an AI Proxy brings its
|
||||
// account, if the admin added any (claude-pool.js) — or the one the
|
||||
// workspace owner is pinned to (account-pins.js); an AI Proxy brings its
|
||||
// own key, and an empty pool leaves the machine's login in charge.
|
||||
// A real login (kind `login`) is written into a MEMBER's home by place();
|
||||
// a setup-token goes in as CLAUDE_CODE_OAUTH_TOKEN.
|
||||
const poolAccount =
|
||||
entry.launcher === "claude" && !proxyProfile
|
||||
? require("./claude-pool").forSpawn(require("./run-as").accountForLane(laneId))
|
||||
? require("./claude-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
|
||||
: null;
|
||||
// Codex likewise, from its own pool (codex-pool.js): an API key through
|
||||
// `-e`, or a ChatGPT login written into the member's CODEX_HOME by place().
|
||||
const codexPool =
|
||||
entry.launcher === "codex" && !proxyProfile
|
||||
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId))
|
||||
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
|
||||
: null;
|
||||
const paneCred = require("./auth").newPaneToken();
|
||||
if (forkSession === true) {
|
||||
@@ -626,6 +635,11 @@ function spawnRun(args) {
|
||||
// journals and the proxy layer `-p <name>` loads live there — and the
|
||||
// tmux server's own env may predate, or never have had, CODEX_HOME.
|
||||
...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}),
|
||||
// The workspace's instructions are a CLAUDE.md in an `--add-dir` folder,
|
||||
// which Claude reads only with this set (lane-sources.js launchExtras).
|
||||
...(entry.launcher === "claude" && systemPromptFile
|
||||
? { CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: "1" }
|
||||
: {}),
|
||||
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
|
||||
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
|
||||
// pushes lines into history its redraw cannot clear). The user's own
|
||||
|
||||
@@ -608,6 +608,119 @@ function enableMouse(name) {
|
||||
} catch {
|
||||
/* tmux < 3.7 */
|
||||
}
|
||||
// The wheel's copy-mode paints a yellow `[0/0]` position box in the pane's
|
||||
// corner; an empty format draws none. A window option of a recent tmux
|
||||
// only — an older one refuses it and keeps the box.
|
||||
try {
|
||||
run(["set-option", "-w", "-t", pane(name), "copy-mode-position-format", ""]);
|
||||
} catch {
|
||||
/* tmux without copy-mode-position-format */
|
||||
}
|
||||
try {
|
||||
if (exact()) onServerOf(name, copyModePromptBindings());
|
||||
} catch {
|
||||
/* an old tmux without `if-shell -F`/`#{m:}`: the prompts stay */
|
||||
}
|
||||
try {
|
||||
if (exact()) onServerOf(name, wheelBindings());
|
||||
} catch {
|
||||
/* an old tmux: the wheel keeps tmux's default speed */
|
||||
}
|
||||
}
|
||||
|
||||
// tmux's wheel scrolls 5 lines a notch, and its first notch only ENTERS
|
||||
// copy-mode without scrolling. In OUR panes a notch is WHEEL_LINES lines and
|
||||
// the first one already scrolls; every other session keeps tmux's default
|
||||
// binding, restored verbatim (same server-wide re-bind as the prompts below).
|
||||
const WHEEL_LINES = 10;
|
||||
const WHEEL_ROOT_GUARD = "#{||:#{alternate_on},#{pane_in_mode},#{mouse_any_flag}}";
|
||||
|
||||
/** One `;`-chained argv re-binding the wheel in `root` and both copy-mode tables. */
|
||||
function wheelBindings() {
|
||||
const scroll = (dir, n) => `select-pane ; send-keys -X -N ${n} scroll-${dir}`;
|
||||
const cmds = [
|
||||
[
|
||||
...["bind-key", "-T", "root", "WheelUpPane", "if-shell", "-F", WHEEL_ROOT_GUARD],
|
||||
"send-keys -M",
|
||||
`if-shell -F "${OURS}" { copy-mode -e ; send-keys -X -N ${WHEEL_LINES} scroll-up } { copy-mode -e }`,
|
||||
],
|
||||
];
|
||||
for (const table of ["copy-mode", "copy-mode-vi"])
|
||||
for (const [key, dir] of [
|
||||
["WheelUpPane", "up"],
|
||||
["WheelDownPane", "down"],
|
||||
])
|
||||
cmds.push([
|
||||
...["bind-key", "-T", table, key, "if-shell", "-F", OURS],
|
||||
scroll(dir, WHEEL_LINES),
|
||||
scroll(dir, 5),
|
||||
]);
|
||||
return cmds.flatMap((c, i) => (i ? [";", ...c] : c));
|
||||
}
|
||||
|
||||
// The wheel puts a pane into copy-mode, and there a typed `g` (`:` with vi
|
||||
// keys), `f`, `/`, a digit… opens a tmux prompt — "(goto line)", "(jump
|
||||
// forward)", "(repeat)" — instead of reaching the CLI. In OUR panes those keys
|
||||
// leave copy-mode and are typed into the program; every other session on the
|
||||
// server (the user's own tmux shares the default socket) keeps tmux's default
|
||||
// binding, restored verbatim below. Key tables are server-wide, so this is
|
||||
// re-bound on every attach like the options above (idempotent).
|
||||
const OURS = "#{m:agenthub-lane-*,#{session_name}}";
|
||||
const COPY_MODE_PROMPTS = {
|
||||
"copy-mode": {
|
||||
g: 'command-prompt -P -p "(goto line)" { send-keys -X goto-line -- "%%" }',
|
||||
f: 'command-prompt -1P -p "(jump forward)" { send-keys -X jump-forward -- "%%" }',
|
||||
F: 'command-prompt -1P -p "(jump backward)" { send-keys -X jump-backward -- "%%" }',
|
||||
t: 'command-prompt -1P -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }',
|
||||
T: 'command-prompt -1P -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }',
|
||||
},
|
||||
"copy-mode-vi": {
|
||||
":": 'command-prompt -P -p "(goto line)" { send-keys -X goto-line -- "%%" }',
|
||||
"/": 'command-prompt -P -T search -p "(search down)" { send-keys -X search-forward -- "%%" }',
|
||||
"?": 'command-prompt -P -T search -p "(search up)" { send-keys -X search-backward -- "%%" }',
|
||||
f: 'command-prompt -1P -p "(jump forward)" { send-keys -X jump-forward -- "%%" }',
|
||||
F: 'command-prompt -1P -p "(jump backward)" { send-keys -X jump-backward -- "%%" }',
|
||||
t: 'command-prompt -1P -p "(jump to forward)" { send-keys -X jump-to-forward -- "%%" }',
|
||||
T: 'command-prompt -1P -p "(jump to backward)" { send-keys -X jump-to-backward -- "%%" }',
|
||||
...Object.fromEntries(
|
||||
[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => [
|
||||
String(n),
|
||||
`command-prompt -NP -I ${n} -p (repeat) { send-keys -N "%%" }`,
|
||||
])
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/** One `;`-chained argv re-binding every key in `COPY_MODE_PROMPTS`. */
|
||||
function copyModePromptBindings() {
|
||||
const cmds = [];
|
||||
for (const [table, keys] of Object.entries(COPY_MODE_PROMPTS))
|
||||
for (const [key, original] of Object.entries(keys))
|
||||
cmds.push([
|
||||
"bind-key",
|
||||
"-T",
|
||||
table,
|
||||
key,
|
||||
"if-shell",
|
||||
"-F",
|
||||
OURS,
|
||||
`send-keys -X cancel ; send-keys -l -- '${key}'`,
|
||||
original,
|
||||
]);
|
||||
return cmds.flatMap((c, i) => (i ? [";", ...c] : c));
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the pane out of copy-mode (or any mode) so what is typed next reaches
|
||||
* the program, not tmux's scrollback keys. A no-op for a pane in no mode;
|
||||
* best-effort — a tmux without `copy-mode -q` (< 3.2) leaves it as it is.
|
||||
*/
|
||||
function leaveCopyMode(name) {
|
||||
try {
|
||||
onServerOf(name, ["copy-mode", "-q", "-t", pane(name)]);
|
||||
} catch {
|
||||
/* session gone or an old tmux */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -898,6 +1011,7 @@ module.exports = {
|
||||
hasSession,
|
||||
newSession,
|
||||
enableMouse,
|
||||
leaveCopyMode,
|
||||
paneCommand,
|
||||
sendCommand,
|
||||
sendText,
|
||||
|
||||
@@ -38,6 +38,10 @@ const schemas = {
|
||||
properties: { mode: { type: "string", enum: ["argv", "flag", "after-start"] } },
|
||||
},
|
||||
resume: { type: "boolean", description: "Offers Resume last session" },
|
||||
resumeById: {
|
||||
type: "boolean",
|
||||
description: "Can resume a named session (Claude, Codex, a catalog `resumeById` such as omp)",
|
||||
},
|
||||
bypass: { type: "boolean", description: "Offers the per-run Bypass permissions switch" },
|
||||
proxyCapable: { type: "boolean", description: "Can use an AI Proxy profile" },
|
||||
install: {
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* - POST /api/auth/logout (Auth)
|
||||
* - GET /api/accounts (Auth) — the Claude and Codex account pools, no keys or tokens
|
||||
* - POST /api/accounts, DELETE /api/accounts/{name} (Auth) — an admin adds/removes one
|
||||
* - PUT /api/accounts/pins (Auth) — an admin pins a user to one pool account
|
||||
* - PUT /api/accounts/{name}/users (Auth) — an account's Edit: exactly who runs on it
|
||||
* - GET /api/users (Auth) — every account's usage (admin/analyst)
|
||||
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
|
||||
* - GET/PUT /api/me/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences
|
||||
@@ -30,6 +32,16 @@ const tags = [
|
||||
];
|
||||
|
||||
const schemas = {
|
||||
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: {
|
||||
type: "object",
|
||||
required: ["prefs", "onboardedAt"],
|
||||
@@ -633,6 +645,15 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
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: {
|
||||
type: "array",
|
||||
description:
|
||||
@@ -706,6 +727,41 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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: err("EBADTOOL"),
|
||||
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||
404: err("ENOUSER, ENOACCOUNT"),
|
||||
429: err("ETOOMANY — too many wrong passwords"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/accounts/profiles": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
@@ -716,6 +772,41 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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: err("EBADTOOL, EBADUSER"),
|
||||
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||
404: err("ENOUSER, ENOACCOUNT"),
|
||||
429: err("ETOOMANY — too many wrong passwords"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/accounts/{name}": {
|
||||
delete: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -504,7 +504,7 @@ const schemas = {
|
||||
resumeSessionId: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional Claude session id to resume via `--resume`. Must match /^[A-Za-z0-9-]{8,}$/ and requires conversation mode, else EBADSESSION / EBADMODE.",
|
||||
"Optional Claude session id to resume via `--resume`. Must match /^[A-Za-z0-9_-]{8,}$/ and requires conversation mode, else EBADSESSION / EBADMODE.",
|
||||
example: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
|
||||
},
|
||||
effort: {
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
* refreshes); `credentials` — the text of a `.credentials.json` (or a macOS
|
||||
* Keychain item's JSON); or `token` — a `claude setup-token` token. A Codex
|
||||
* one is an OpenAI API key or the text of an `auth.json` (`token`).
|
||||
* `PUT /pins` (`{tool, userId, account}`, `account: null` unpins) pins one
|
||||
* user's sessions of a tool to one pool account (account-pins.js) — a Claude
|
||||
* login goes into their home at once, so running panes move without a
|
||||
* restart (`switched`); `PUT /:name/users` (`{tool, userIds}`) is the
|
||||
* Users page's Edit of one account — exactly those users run on it; `GET /`
|
||||
* lists the pins under `pins`.
|
||||
* `GET /profiles` lists the saved profiles that can be moved in (identity only). Like every Users-page change
|
||||
* they need the admin's own password again (`currentPassword`), are audited
|
||||
* (the account NAME only), and refuse in local mode. The secret only goes IN:
|
||||
@@ -29,6 +35,7 @@ const codexPool = require("../lib/codex-pool");
|
||||
const auth = require("../lib/auth");
|
||||
const { recordAccountChange } = require("../lib/audit");
|
||||
const profiles = require("../lib/cc-profiles");
|
||||
const pins = require("../lib/account-pins");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -39,6 +46,9 @@ router.get("/", async (req, res) => {
|
||||
res.json({
|
||||
accounts: pool.poolStatus(),
|
||||
codex: codexPool.poolStatus(),
|
||||
pins: auth.isServerMode()
|
||||
? { claude: pins.listPins("claude"), codex: pins.listPins("codex") }
|
||||
: { claude: [], codex: [] },
|
||||
blockPercent: pool.BLOCK_PERCENT,
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -69,9 +79,11 @@ const STATUS = {
|
||||
ELOCALMODE: 403,
|
||||
EREAUTH: 403,
|
||||
EBADTOOL: 400,
|
||||
EBADUSER: 400,
|
||||
EBADNAME: 400,
|
||||
EBADTOKEN: 400,
|
||||
ENOACCOUNT: 404,
|
||||
ENOUSER: 404,
|
||||
EEXISTS: 409,
|
||||
ETOOMANY: 429,
|
||||
ESTALE: 409, // a pasted login whose refresh token was already spent
|
||||
@@ -93,7 +105,9 @@ function poolChange(handler) {
|
||||
}
|
||||
await auth.confirmPassword(req.user, body.currentPassword);
|
||||
const out = await handler(req, body);
|
||||
recordAccountChange(req.user.id, null, req.method, out.action);
|
||||
for (const a of out.audits || [{ target: out.target, action: out.action }]) {
|
||||
recordAccountChange(req.user.id, a.target ?? null, req.method, a.action);
|
||||
}
|
||||
res.status(out.status || 200).json(out.json);
|
||||
} catch (e) {
|
||||
const status = STATUS[e.code];
|
||||
@@ -203,6 +217,58 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
"/pins",
|
||||
poolChange(async (_req, body) => {
|
||||
const pin = pins.setPin(Number(body.userId), body.tool, body.account ?? null);
|
||||
const switched = pin.account
|
||||
? await applyNow(pin.userId, pin.username, body.tool, pin.account)
|
||||
: null;
|
||||
return {
|
||||
json: { pin, switched },
|
||||
target: pin.userId,
|
||||
action: pin.account
|
||||
? `pool: pin ${pin.username} to ${body.tool} account ${pin.account}`
|
||||
: `pool: unpin ${pin.username} from ${body.tool}`,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
/** Switch an added user now; a failed switch still leaves the pin for the next spawn. */
|
||||
async function applyNow(userId, username, tool, account) {
|
||||
try {
|
||||
return await pins.applyPin(userId, tool);
|
||||
} catch (e) {
|
||||
console.warn(`[accounts] switch ${username} → ${account}: ${e.message}`);
|
||||
return "next-session";
|
||||
}
|
||||
}
|
||||
|
||||
router.put(
|
||||
"/:name/users",
|
||||
poolChange(async (req, body) => {
|
||||
const account = req.params.name;
|
||||
const { added, removed } = pins.setAccountUsers(body.tool, account, body.userIds);
|
||||
const switched = [];
|
||||
for (const u of added) {
|
||||
switched.push({ ...u, switched: await applyNow(u.userId, u.username, body.tool, account) });
|
||||
}
|
||||
return {
|
||||
json: { added: switched, removed },
|
||||
audits: [
|
||||
...added.map((u) => ({
|
||||
target: u.userId,
|
||||
action: `pool: pin ${u.username} to ${body.tool} account ${account}`,
|
||||
})),
|
||||
...removed.map((u) => ({
|
||||
target: u.userId,
|
||||
action: `pool: unpin ${u.username} from ${body.tool}`,
|
||||
})),
|
||||
],
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:name",
|
||||
poolChange((req, body) => {
|
||||
|
||||
+34
-3
@@ -1458,7 +1458,33 @@ function antigravityHookInput(hookType, data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const HOOK_TOOLS = { codex: codexHookInput, antigravity: antigravityHookInput };
|
||||
/**
|
||||
* A Grok Build (`grok`) hook. Grok runs the hooks in `~/.claude/settings.json`
|
||||
* itself (its Claude compatibility, on by default), so AgentHub's own Claude
|
||||
* hook reaches here with no `tool` — told apart by the camelCase keys only
|
||||
* Grok sends beside Claude's snake_case aliases (`hookEventName`,
|
||||
* `workspaceRoot`). Its `transcript_path` is Grok's file, never a Claude
|
||||
* transcript, so it is dropped before anything could parse it.
|
||||
*/
|
||||
const isGrokPayload = (data) =>
|
||||
!!data &&
|
||||
typeof data === "object" &&
|
||||
typeof data.hookEventName === "string" &&
|
||||
typeof data.workspaceRoot === "string";
|
||||
|
||||
function grokHookInput(hookType, data) {
|
||||
if (typeof data.session_id !== "string" || !data.session_id) return null;
|
||||
const d = { ...data };
|
||||
delete d.transcript_path;
|
||||
delete d.agent_transcript_path;
|
||||
return { hookType, data: d };
|
||||
}
|
||||
|
||||
const HOOK_TOOLS = {
|
||||
codex: codexHookInput,
|
||||
antigravity: antigravityHookInput,
|
||||
grok: grokHookInput,
|
||||
};
|
||||
|
||||
/**
|
||||
* Record which session a dashboard pane holds. The pane's CLI inherits
|
||||
@@ -1524,9 +1550,14 @@ function ingestHook(body, pushSource = null, peer = { kind: "host" }) {
|
||||
}
|
||||
const ignored = { status: 200, body: { ok: true, ignored: true } };
|
||||
// Which CLI sent it: `codex` from Codex's hooks (codex-hooks.js),
|
||||
// `antigravity` from agy's (antigravity-hooks.js), anything else is Claude
|
||||
// `antigravity` from agy's (antigravity-hooks.js), `grok` for Grok running
|
||||
// the Claude hooks (by its payload's own keys), anything else is Claude
|
||||
// Code — an allowlist, never a value stored as sent.
|
||||
const tool = Object.hasOwn(HOOK_TOOLS, body.tool) ? body.tool : "claude";
|
||||
const tool = Object.hasOwn(HOOK_TOOLS, body.tool)
|
||||
? body.tool
|
||||
: isGrokPayload(data)
|
||||
? "grok"
|
||||
: "claude";
|
||||
if (tool !== "claude") {
|
||||
if (!data || typeof data !== "object") return ignored;
|
||||
require("../lib/hook-auth").dropForeignPaths(peer, data);
|
||||
|
||||
+170
-6
@@ -840,6 +840,45 @@ router.get("/:id/transcripts", async (req, res) => {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
||||
}
|
||||
|
||||
// An omp session's journals are known by path (omp-usage.js): its own,
|
||||
// then one per subagent, each tied to the agent row made from it.
|
||||
if (session.tool === "omp") {
|
||||
const omp = require("../lib/omp-usage");
|
||||
const list = [];
|
||||
if (omp.journalPath(req.params.id)) {
|
||||
list.push({ id: "main", name: "Main Agent", type: "main", has_transcript: true, db_agent_id: null });
|
||||
}
|
||||
for (const j of omp.subagentJournals(req.params.id)) {
|
||||
list.push({
|
||||
id: j.agent,
|
||||
name: j.name,
|
||||
type: "subagent",
|
||||
subagent_type: "task",
|
||||
has_transcript: true,
|
||||
db_agent_id: stmts.getAgent.get(j.rowId) ? j.rowId : null,
|
||||
});
|
||||
}
|
||||
return res.json({ transcripts: list });
|
||||
}
|
||||
// OpenCode: a subagent is a child session of the store, tied to the agent
|
||||
// row its poller made.
|
||||
if (session.tool === "opencode") {
|
||||
const list = [
|
||||
{ id: "main", name: "Main Agent", type: "main", has_transcript: true, db_agent_id: null },
|
||||
];
|
||||
for (const sub of require("../lib/opencode-usage").subagentSessions(req.params.id)) {
|
||||
list.push({
|
||||
id: sub.id,
|
||||
name: sub.name,
|
||||
type: "subagent",
|
||||
subagent_type: "task",
|
||||
has_transcript: true,
|
||||
db_agent_id: stmts.getAgent.get(sub.rowId) ? sub.rowId : null,
|
||||
});
|
||||
}
|
||||
return res.json({ transcripts: list });
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
// Query database agent list for db_agent_id association
|
||||
@@ -1071,7 +1110,25 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
// A Codex session's conversation is its root thread's rollout journal
|
||||
// (`codex_rollouts`, recorded by codex-usage.js), never a Claude transcript.
|
||||
const isCodex = session.tool === "codex";
|
||||
if (isCodex) {
|
||||
// An omp session's (or subagent's) journal, recorded by omp-usage.js.
|
||||
const isOmp = session.tool === "omp";
|
||||
// An OpenCode conversation lives in its SQLite store, not a JSONL file: read
|
||||
// whole (one session's rows), then paged like the files below.
|
||||
if (session.tool === "opencode") {
|
||||
let all = null;
|
||||
try {
|
||||
all = require("../lib/opencode-usage").transcriptMessages(
|
||||
req.params.id,
|
||||
isSubagentFile ? agentId : null
|
||||
);
|
||||
} catch {
|
||||
all = null;
|
||||
}
|
||||
return res.json(pageMessages(all || [], { limit, afterLine, beforeLine, offset }));
|
||||
}
|
||||
if (isOmp) {
|
||||
jsonlPath = require("../lib/omp-usage").journalPath(req.params.id, agentId || "");
|
||||
} else if (isCodex) {
|
||||
jsonlPath = db
|
||||
.prepare("SELECT path FROM codex_rollouts WHERE session_id = ? AND thread_id = ?")
|
||||
.get(req.params.id, req.params.id)?.path;
|
||||
@@ -1105,7 +1162,14 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
|
||||
const renders = isCodex
|
||||
? (e) => e.type === "response_item"
|
||||
: (e) => TRANSCRIPT_RENDER_TYPES.has(e.type);
|
||||
: isOmp
|
||||
? (e) => e.type === "message"
|
||||
: (e) => TRANSCRIPT_RENDER_TYPES.has(e.type);
|
||||
const toMessage = isCodex
|
||||
? codexMessage
|
||||
: isOmp
|
||||
? (e, n) => ompMessage(e, n, isSubagentFile)
|
||||
: (e, n) => parseMessage(e, n);
|
||||
|
||||
// Dedupe state for synthetic rename markers: custom-title lines can repeat
|
||||
// with the same value across a transcript, so only emit when the title
|
||||
@@ -1282,7 +1346,7 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
}
|
||||
if (!renders(entry)) continue;
|
||||
|
||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
||||
const message = toMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
messages.push(message);
|
||||
@@ -1313,7 +1377,7 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
break;
|
||||
}
|
||||
|
||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
||||
const message = toMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
messages.push(message);
|
||||
@@ -1337,7 +1401,7 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
}
|
||||
if (!renders(entry)) continue;
|
||||
|
||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
||||
const message = toMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
|
||||
@@ -1364,7 +1428,7 @@ router.get("/:id/transcript", async (req, res) => {
|
||||
}
|
||||
if (!renders(entry)) continue;
|
||||
|
||||
const message = isCodex ? codexMessage(entry, lineNum) : parseMessage(entry, lineNum);
|
||||
const message = toMessage(entry, lineNum);
|
||||
if (!message) continue;
|
||||
total++;
|
||||
messages.push(message);
|
||||
@@ -1473,6 +1537,106 @@ function codexMessage(entry, num) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One omp journal `message` line → a transcript message in the Claude shape
|
||||
* the client renders, or null. A user line omp wrote for itself (no
|
||||
* `attribution: "user"`) is the system's — in a subagent's journal, the first
|
||||
* one is the orchestrator's task.
|
||||
*/
|
||||
function ompMessage(entry, num, isSubagent = false) {
|
||||
const m = entry.message || {};
|
||||
const at = entry.timestamp || null;
|
||||
const msg = (type, sender, content) => ({ type, sender, timestamp: at, content, line: num });
|
||||
const parts = Array.isArray(m.content)
|
||||
? m.content
|
||||
: typeof m.content === "string"
|
||||
? [{ type: "text", text: m.content }]
|
||||
: [];
|
||||
switch (m.role) {
|
||||
case "user": {
|
||||
const content = parts
|
||||
.filter((c) => c && c.type === "text" && typeof c.text === "string" && c.text.trim())
|
||||
.map((c) => ({ type: "text", text: truncate(c.text, TEXT_MAX) }));
|
||||
if (!content.length) return null;
|
||||
const sender = m.attribution === "user" ? "user" : isSubagent ? "orchestrator" : "system";
|
||||
return msg("user", sender, content);
|
||||
}
|
||||
case "assistant": {
|
||||
const content = [];
|
||||
for (const c of parts) {
|
||||
if (!c) continue;
|
||||
if (c.type === "text" && typeof c.text === "string" && c.text.trim()) {
|
||||
content.push({ type: "text", text: truncate(c.text, TEXT_MAX) });
|
||||
} else if (c.type === "thinking" && typeof c.thinking === "string" && c.thinking.trim()) {
|
||||
content.push({ type: "thinking", text: truncate(c.thinking, 10240) });
|
||||
} else if (c.type === "toolCall") {
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
name: c.name || "unknown",
|
||||
id: c.id || null,
|
||||
input: truncateObj(c.arguments && typeof c.arguments === "object" ? c.arguments : {}, 10240),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!content.length) return null;
|
||||
const out = msg("assistant", "assistant", content);
|
||||
if (m.model) out.model = m.model;
|
||||
return out;
|
||||
}
|
||||
case "toolResult": {
|
||||
const text = parts
|
||||
.map((c) => (c && c.type === "text" && typeof c.text === "string" ? c.text : ""))
|
||||
.join("");
|
||||
return msg("user", "tool", [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: m.toolCallId || null,
|
||||
output: truncate(text, 10240),
|
||||
is_error: m.isError === true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The transcript route's paging over a conversation already in memory (an
|
||||
* OpenCode store): message N is "line" N, so `after`/`before`/`offset` and the
|
||||
* default latest-`limit` window answer exactly as they do over a file.
|
||||
*/
|
||||
function pageMessages(all, { limit, afterLine, beforeLine, offset }) {
|
||||
const lined = all.map((m, i) => ({ ...m, line: i + 1 }));
|
||||
let picked;
|
||||
let hasMore;
|
||||
if (afterLine !== null) {
|
||||
const rest = lined.filter((m) => m.line > afterLine);
|
||||
picked = rest.slice(0, limit);
|
||||
hasMore = rest.length > limit;
|
||||
} else if (beforeLine !== null) {
|
||||
const older = lined.filter((m) => m.line < beforeLine);
|
||||
picked = older.slice(-limit);
|
||||
hasMore = older.length > limit;
|
||||
} else if (offset > 0) {
|
||||
picked = lined.slice(offset, offset + limit);
|
||||
hasMore = lined.length > offset + limit;
|
||||
} else {
|
||||
picked = lined.slice(-limit);
|
||||
hasMore = lined.length > limit;
|
||||
}
|
||||
const lastLine = picked.length ? picked[picked.length - 1].line : 0;
|
||||
const firstLine = picked.length ? picked[0].line : 0;
|
||||
for (const m of picked) delete m.line;
|
||||
return {
|
||||
messages: picked,
|
||||
total: lined.length,
|
||||
has_more: hasMore,
|
||||
last_line: lastLine,
|
||||
first_line: firstLine,
|
||||
};
|
||||
}
|
||||
|
||||
/** The +/- line counts of a Write/Edit/MultiEdit result (`toolUseResult`):
|
||||
* its `structuredPatch` hunks' `+`/`-` lines, or every line of a file it
|
||||
* created. Null for any other result. */
|
||||
|
||||
Reference in New Issue
Block a user