Compare commits

...

5 Commits

Author SHA1 Message Date
nntrivi2001 c8bc4a95a9 chore: v2.8.23 — workspace groups, inner repos in Changes (opt-in), sub-agent tokens counted
Workspace groups: named, foldable, ordered sidebar sections kept per
account (agenthub.workspace.groups); a workspace joins one from its menu,
a drop on the header, a drag under a row, or Alt+Up/Down past a group's
edge; headers drag to reorder; a folded header shows its most urgent
state; New workspace in this group; fold/unfold all.

Inner repos: GET /api/lanes/:id/repos + ?repo= on git/fs routes; the
Changes panel shows a parent folder's repos only when Settings -> Git ->
Changes panel turns it on (agenthub.changes.innerRepos, off by default).

Cost: the hook writer adds a session's sub-agent transcripts to its
tokens and hours (server/lib/subagent-usage.js) — a sub-agent on the
session's own model was never counted live (~8% of a week here); an
imported session is re-raised on every pass, so a claude -p run first
seen with an empty transcript no longer stays at $0 (~10%). A one-time
raise-only catch-up (user_version 5) re-reads ~/.claude/projects.

Also: omp install script may redirect to its exact install.redirect URL
(#85, repo-links test exempts it), native paste in the terminal.
2026-10-23 16:41:45 +07:00
nntrivi2001 65a001dad4 fix(lanes): cloning into a deleted workspace's folder re-attaches it instead of failing UNIQUE
A workspace record outlives its folder (deleted from the File Explorer or a
shell). Cloning the same repo into that path again ran `git clone`, then
createLane hit idx_lanes_cwd_tool and answered 500 "UNIQUE constraint failed:
lanes.cwd, lanes.tool", leaving the new clone with no workspace on it.

POST /api/lanes/clone now looks for the caller's own workspace at exactly
that folder and tool and answers 200 { lane, created: false, path }, like
POST /ensure. Another user's workspace there is still refused before the
clone (folderTakenByOther), and a nested path still makes its own workspace.
2026-10-23 16:28:18 +07:00
nntrivi2001 572d615d4e chore: v2.8.22 — Users today/7/30-day spend counts every tool, update chip follows a running update
- Users: today / 7 / 30-day spend read only token_usage_hourly, which holds
  Claude transcript hours only — omp/Codex/Antigravity/Cursor sessions,
  sub-agent files, baselines and long-context pricing were missing. Now
  booked by hourlyTokenRows + calculateCost like the cost routes.
- Update: a page adopts a plugin update the server reports as running
  (after a reload, another tab, or one started from an older bundle), and
  the progress chip shows whenever an update runs and the modal is not
  visible — also when the status stops offering it mid-run.
2026-10-23 11:26:12 +07:00
nntrivi2001 9dd97a369c chore: v2.8.21 — update progress steps, clone in background, extra SSH keys inline, today's spend
- Plugin update reports its running step (plugin-update-progress.json →
  plugin_update_progress); a hidden update or clone leaves a progress chip.
- Clone here can run in the background.
- Git identity: extra SSH keys are added inline per host (no More keys tab).
- Users: spend today (viewer's local day, ?tz_offset=).
- Prettier on the files touched since v2.8.20.
2026-10-23 10:44:11 +07:00
nntrivi2001 62b4520507 feat(artifacts): capture Claude Docs docs and session documents, claim unowned rows
- A Claude Docs `batch` that creates a doc is captured like an `Artifact`
  publish, titled by the doc's name and linked by its own frame URL.
- At Stop/SessionEnd, each pptx/docx/xlsx/pdf/html a workspace session
  created or changed is snapshotted into its owner's library (`auto`);
  an unchanged file is skipped, a changed one refreshes its snapshot.
- Server mode: a page with no owner carries a suggested owner (the
  workspace or home its session ran in). The suggested user claims or
  declines it; an admin claims or assigns any. New tab Unowned /
  Is this yours? on the Artifacts page; all three actions audited.
2026-10-23 08:33:55 +07:00
86 changed files with 4294 additions and 590 deletions
+5 -5
View File
File diff suppressed because one or more lines are too long
+84 -6
View File
@@ -19,7 +19,12 @@
* background process can do for it. Escape, the backdrop and × close it for
* this page load only — also while an update applies: it keeps running in the
* background (the page still reloads itself once the new server is up), and a
* failure re-opens the modal with its error; "Skip this version" is the one
* failure re-opens the modal with its error. While hidden — or once the status
* stops offering the update mid-run — a small chip in the corner keeps showing
* it, with the plugin chain's current step from `plugin_update_progress`, and a
* click on it shows the modal again. A chain the server reports as running
* (started before a reload or from another tab) is adopted the same way;
* "Skip this version" is the one
* persistent, per-version dismiss (never while applying). After a failed update, "Auto fix" hands the
* error to an AI agent: it opens (or adopts) a workspace on the install folder
* and starts a Claude Code session there with the error as its prompt — the
@@ -184,6 +189,8 @@ export function UpdateNotifier() {
const [applying, setApplying] = useState(false);
const [restarting, setRestarting] = useState(false);
const [fixing, setFixing] = useState(false);
/** The running plugin chain's step, from the polls while it runs. */
const [progress, setProgress] = useState<UpdateStatusPayload["plugin_update_progress"]>(null);
const navigate = useNavigate();
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
@@ -238,13 +245,14 @@ export function UpdateNotifier() {
status?.latest_release ||
status?.remote_sha ||
(installed ? `installed:${installed.root}` : null);
// Updating replaces the host's install: only an admin is offered it.
// Updating replaces the host's install: only an admin is offered it. A
// running update stays shown (unless closed) even once the status stops
// offering it — the new version may already be on disk before it restarts.
const show = Boolean(
isAdmin &&
status?.update_available &&
dismissKey &&
dismissedSha !== dismissKey &&
closedKey !== dismissKey
closedKey !== dismissKey &&
(applying || restarting || (status?.update_available && dismissedSha !== dismissKey))
);
const busy = applying || restarting || fixing;
@@ -312,6 +320,7 @@ export function UpdateNotifier() {
api.updates
.status()
.then((s) => {
setProgress(s.plugin_update_progress ?? null);
const v = s.last_plugin_update;
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
// A failed step, or nothing to pull, leaves the OLD server up: it
@@ -339,9 +348,23 @@ export function UpdateNotifier() {
[syncFromPayload, t]
);
// A chain already running on the server (started before a reload, in another
// tab, or from the old bundle) is adopted: this tab shows its steps, then
// reloads onto the new server like the tab that started it.
const running = status?.plugin_update_progress ?? null;
const adoptedRef = useRef(false);
useEffect(() => {
if (!running || !isAdmin || applying || restarting || adoptedRef.current) return;
adoptedRef.current = true;
setProgress(running);
setRestarting(true);
pollUntilBack(true, status?.local_version ?? null);
}, [running, isAdmin, applying, restarting, pollUntilBack, status?.local_version]);
const applyNow = async () => {
if (applying || restarting) return;
setError(null);
setProgress(null);
setApplying(true);
try {
const result = await api.updates.apply();
@@ -392,6 +415,54 @@ export function UpdateNotifier() {
}
};
const stepText = progress?.step
? t(`pluginStep.${progress.step}`, {
index: progress.index,
total: progress.total,
defaultValue: progress.step,
})
: null;
// Hidden while it runs — closed, or the status no longer offers the update
// (the new version is already on disk): a chip keeps it in sight.
if (!show && isAdmin && (applying || restarting)) {
return (
<button
type="button"
data-testid="update-chip"
onClick={() => setClosedKey(null)}
title={t("chipHint")}
className="fixed bottom-24 right-4 z-[60] w-72 rounded-lg border border-accent/30 bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
>
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" aria-hidden />
<span className="truncate">
{status?.latest_release
? t("chipTitle", { version: status.latest_release })
: t("updating")}
</span>
</span>
<span className="mt-1 block truncate text-[11px] text-fg-muted">
{stepText || (applying ? t("updating") : t("restartingShort"))}
</span>
{progress && progress.total > 0 ? (
<span
className="mt-1.5 block h-1 overflow-hidden rounded bg-surface-2"
role="progressbar"
aria-valuenow={progress.index}
aria-valuemin={0}
aria-valuemax={progress.total}
>
<span
className="block h-full bg-accent-solid transition-all"
style={{ width: `${(Math.max(progress.index - 1, 0) / progress.total) * 100}%` }}
/>
</span>
) : null}
</button>
);
}
if (!show || !status) return null;
const refLabel = status.remote_ref || "origin";
@@ -520,7 +591,14 @@ export function UpdateNotifier() {
{/* The plugin chain pulls a new cache directory and reinstalls
dependencies before it restarts, so it takes minutes, not
seconds — saying "restarting" there would look hung. */}
{pluginManaged ? t("pluginUpdating") : t("restarting")}
<span>
{pluginManaged ? t("pluginUpdating") : t("restarting")}
{stepText ? (
<span className="block mt-0.5 font-medium" data-testid="update-step">
{stepText}
</span>
) : null}
</span>
</div>
) : null}
+415 -10
View File
@@ -15,6 +15,16 @@
* first, no owner last, groups foldable — `collapsedOwners`); one this user
* may not change (`canEditLane`) is read only — no menu, no new session, and a
* session opens its analysis page (`/sessions/<id>`), never a pane.
* The user's own GROUPS (`lib/workspaceGroups.ts`, kept per account on the
* server like the other preferences) arrange the list under foldable headers —
* each group in its order, then "Ungrouped" — inside each owner group when
* there are owner groups. A workspace joins a group from its menu, by a drop
* on the group's header, by a drag that lands it below a row of that
* group, or by Alt+↑/↓ past the group's edge; a header is dragged onto
* another to reorder the groups, and its menu renames, moves, folds every
* group, adds a new workspace straight into it or deletes it (its
* workspaces stay, ungrouped). A folded header still shows the worst state
* of its workspaces (needs you, error, running) and marks the selected one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -30,7 +40,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { ChevronDown, ChevronRight, Plus, Search } from "lucide-react";
import { ChevronDown, ChevronRight, FolderPlus, MoreHorizontal, Plus, Search } from "lucide-react";
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
import type { Lane } from "../lib/types";
import { useLanes } from "../hooks/useLanes";
@@ -46,7 +56,31 @@ import {
writeLaneOrder,
} from "../lib/laneOrderStorage";
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
import {
arrangeByGroup,
assignGroup,
createGroup,
deleteGroup,
groupOf,
moveGroup,
moveGroupBefore,
pruneWorkspaceGroups,
renameGroup,
setAllFolded,
setGroupFolded,
useWorkspaceGroups,
type WorkspaceGroup,
} from "../lib/workspaceGroups";
import { confirmDialog } from "../lib/confirm";
import { ContextMenu, MenuItem, MenuSeparator } from "./ContextMenu";
import {
LANE_DRAG_TYPE,
WorkspaceRow,
isLaneDrag,
workspaceDot,
workspaceDotLabel,
workspaceName,
} from "./lanes/WorkspaceRow";
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
import { UserAvatar } from "./users/UserAvatar";
@@ -98,8 +132,23 @@ export function WorkspaceTree() {
);
const [addOpen, setAddOpen] = useState(false);
const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null);
// "New workspace in this group": the group the added workspace joins.
const [addToGroup, setAddToGroup] = useState<string | null>(null);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [orderTick, setOrderTick] = useState(0);
const groups = useWorkspaceGroups();
const hasGroups = groups.groups.length > 0;
const [renamingGroup, setRenamingGroup] = useState<string | null>(null);
const [groupMenu, setGroupMenu] = useState<{
group: WorkspaceGroup;
x: number;
y: number;
} | null>(null);
// A removed workspace leaves the stored groups (they hold ids only).
const laneIdsKey = lanes.map((l) => l.id).join(",");
useEffect(() => {
if (laneIdsKey) pruneWorkspaceGroups(laneIdsKey.split(",").map(Number));
}, [laneIdsKey]);
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
// status or session moves, so that is when the live-pane list is re-read;
@@ -126,8 +175,9 @@ export function WorkspaceTree() {
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
}, []);
const openAdd = (sourceRepo?: string) => {
const openAdd = (sourceRepo?: string, groupId: string | null = null) => {
setAddPrefill(sourceRepo ? { sourceRepo } : null);
setAddToGroup(groupId);
setAddOpen(true);
api.run
.cwds()
@@ -148,8 +198,9 @@ export function WorkspaceTree() {
const toolFilter =
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
const sorted = useMemo(() => {
const list = sortByStoredOrder(lanes, order).filter(
(l) => !toolFilter || l.tool === toolFilter
const list = arrangeByGroup(
sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
groups
);
if (!grouped) return list;
// Stable: each owner's workspaces keep the stored order inside the group.
@@ -157,7 +208,7 @@ export function WorkspaceTree() {
return [...list].sort(
(a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b))
);
}, [lanes, order, toolFilter, grouped, meId]);
}, [lanes, order, toolFilter, grouped, meId, groups]);
const q = query.trim().toLowerCase();
const nameHit = (l: Lane) =>
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
@@ -190,6 +241,26 @@ export function WorkspaceTree() {
setOrderTick((n) => n + 1);
};
// Alt+↑/↓. Past the edge of its group the workspace stays where it is on
// screen and joins the next group instead (the arrangement would otherwise
// put it straight back): last of the group above, first of the one below —
// opened if folded, so the focused row stays shown. The shown order is
// written as it stands, so the store agrees. Never across an owner's section.
const nudge = (from: number, to: number) => {
const lane = sorted[from];
const next = sorted[to];
if (lane && next && hasGroups && ownerKey(lane) === ownerKey(next)) {
const target = groupOf(groups, next.id);
if ((target?.id ?? null) !== (groupOf(groups, lane.id)?.id ?? null)) {
assignGroup(lane.id, target?.id ?? null);
if (target?.folded) setGroupFolded(target.id, false);
reorder((ids) => [...ids]);
return;
}
}
reorder((ids) => moveLane(ids, from, to));
};
// A row only starts a drag; the whole section takes the drop, so a gap — a
// workspace's session list, the space above or below a row — lands too.
// `dropAt` is the insertion point in `sorted`, drawn as a line.
@@ -223,11 +294,74 @@ export function WorkspaceTree() {
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
// Number("") is 0: an empty payload must not move the first row.
const from = raw === "" ? NaN : Number(raw);
if (!Number.isInteger(from)) return;
const lane = sorted[from];
// Dropped on a group's header: into that group, at its top.
const header = (e.target as HTMLElement).closest<HTMLElement>("[data-group-drop]");
if (header && lane) {
const gid = header.dataset.groupDrop || null;
assignGroup(lane.id, gid);
const first = sorted.findIndex(
(l) => (groups.of[l.id] ?? null) === gid && l.id !== lane.id
);
if (first >= 0) reorder((ids) => moveBefore(ids, from, first));
return;
}
const before = dropIndexAt(e.clientY);
if (Number.isInteger(from)) reorder((ids) => moveBefore(ids, from, before));
// Elsewhere it joins the group of the row it now sits under (the line
// drawn above a header closes the section before it).
if (lane && hasGroups) {
const rest = sorted.filter((l) => l.id !== lane.id);
const at = sorted.slice(0, before).filter((l) => l.id !== lane.id).length;
const neighbour = rest[at - 1] ?? rest[0];
if (neighbour) assignGroup(lane.id, groups.of[neighbour.id] ?? null);
}
reorder((ids) => moveBefore(ids, from, before));
},
};
// A group no workspace is in yet still shows (to be renamed, or dropped on),
// just above the ungrouped ones; a search hides it.
const emptyGroups = q
? []
: groups.groups.filter((g) => !sorted.some((l) => groups.of[l.id] === g.id));
const firstUngrouped = hasGroups ? sorted.findIndex((l) => !groupOf(groups, l.id)) : -1;
const groupHeader = (g: WorkspaceGroup | null, members: Lane[], first: boolean) => (
<GroupHeader
key={g?.id ?? "none"}
group={g}
count={members.length}
// What a folded group hides: the most urgent of its workspaces, and
// whether the open one is among them.
worst={worstOf(members)}
holdsSelected={members.some((l) => l.id === selected.lane)}
onGroupDrop={(dragged) => moveGroupBefore(dragged, g?.id ?? null)}
folded={!!g?.folded && !q}
renaming={!!g && renamingGroup === g.id}
first={first}
onToggle={() => g && setGroupFolded(g.id, !g.folded)}
onMenu={(x, y) => g && setGroupMenu({ group: g, x, y })}
onRenameEnd={(name) => {
setRenamingGroup(null);
if (g && name !== null) renameGroup(g.id, name);
}}
/>
);
const newGroup = (laneIds: number[] = []) => {
const id = createGroup(t("tree.groupDefaultName"), laneIds);
setRenamingGroup(id);
};
const removeGroup = async (g: WorkspaceGroup) => {
const ok = await confirmDialog({
title: t("tree.groupDelete"),
message: t("tree.groupDeleteConfirm", { name: g.name }),
confirmLabel: t("tree.groupDelete"),
});
if (!ok) return;
deleteGroup(g.id);
};
const rename = async (lane: Lane, title: string | null) => {
setRenamingId(null);
if (title === null || title === (lane.title || "")) return;
@@ -304,6 +438,16 @@ export function WorkspaceTree() {
>
<Search className="h-3.5 w-3.5" />
</button>
<button
type="button"
data-testid="workspace-tree-new-group"
onClick={() => newGroup()}
aria-label={t("tree.newGroup")}
title={t("tree.newGroup")}
className={headBtn}
>
<FolderPlus className="h-3.5 w-3.5" />
</button>
<button
type="button"
data-testid="workspace-tree-add"
@@ -347,6 +491,16 @@ export function WorkspaceTree() {
: ownerShown(lane) || `#${lane.owner_id}`;
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
// The user's own group, inside the owner's.
const ug = groupOf(groups, lane.id);
const ugKey = ug?.id ?? "";
const sameSection = (l: Lane) =>
ownerKey(l) === owner && (groupOf(groups, l.id)?.id ?? "") === ugKey;
const prev = sorted[i - 1];
const sectionStart = hasGroups && (groupStart || !prev || !sameSection(prev));
const sectionShown = sorted.some((l) => sameSection(l) && visible(l));
const sectionFolded = !!ug?.folded && !q;
const hiddenRow = folded || sectionFolded;
return (
<div key={lane.id} className="relative">
{groupStart && groupShown && (
@@ -381,8 +535,13 @@ export function WorkspaceTree() {
</span>
</button>
)}
{i === firstUngrouped && emptyGroups.map((g) => groupHeader(g, [], false))}
{sectionStart &&
sectionShown &&
!folded &&
groupHeader(ug, sorted.filter(sameSection), i === 0 || !!groupStart)}
{dropAt === i && <DropLine />}
{visible(lane) && !folded && (
{visible(lane) && !hiddenRow && (
<WorkspaceRow
lane={lane}
index={i}
@@ -398,13 +557,13 @@ export function WorkspaceTree() {
}}
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
onNewSession={() => select(lane.id)}
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
onReorder={nudge}
onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)}
/>
)}
{expanded(lane.id) && !folded && (
{expanded(lane.id) && !hiddenRow && (
<WorkspaceSessionList
lane={lane}
liveRuns={liveRuns}
@@ -420,6 +579,8 @@ export function WorkspaceTree() {
</div>
);
})}
{firstUngrouped === -1 &&
emptyGroups.map((g, k) => groupHeader(g, [], k === 0 && sorted.length === 0))}
{dropAt === sorted.length && (
<div className="relative">
<DropLine />
@@ -440,8 +601,92 @@ export function WorkspaceTree() {
y={menu.y}
onClose={() => setMenu(null)}
onAction={(a) => onMenuAction(menu.lane, a)}
groups={groups.groups}
groupId={groups.of[menu.lane.id] ?? null}
onGroup={(target) => {
const id = menu.lane.id;
setMenu(null);
if (target === "new") newGroup([id]);
else assignGroup(id, target);
}}
/>
)}
{groupMenu && (
<ContextMenu
x={groupMenu.x}
y={groupMenu.y}
onClose={() => setGroupMenu(null)}
testId={`workspace-group-menu-${groupMenu.group.id}`}
>
<MenuItem
onSelect={() => {
setRenamingGroup(groupMenu.group.id);
setGroupMenu(null);
}}
>
{t("tree.groupRename")}
</MenuItem>
<MenuItem
disabled={groups.groups[0]?.id === groupMenu.group.id}
onSelect={() => {
moveGroup(groupMenu.group.id, -1);
setGroupMenu(null);
}}
>
{t("tree.groupMoveUp")}
</MenuItem>
<MenuItem
disabled={groups.groups[groups.groups.length - 1]?.id === groupMenu.group.id}
onSelect={() => {
moveGroup(groupMenu.group.id, 1);
setGroupMenu(null);
}}
>
{t("tree.groupMoveDown")}
</MenuItem>
<MenuSeparator />
<MenuItem
testId="workspace-group-add"
onSelect={() => {
const g = groupMenu.group;
setGroupMenu(null);
openAdd(undefined, g.id);
}}
>
{t("tree.groupAddWorkspace")}
</MenuItem>
<MenuItem
testId="workspace-group-fold-all"
onSelect={() => {
setGroupMenu(null);
setAllFolded(true);
}}
>
{t("tree.groupFoldAll")}
</MenuItem>
<MenuItem
testId="workspace-group-unfold-all"
onSelect={() => {
setGroupMenu(null);
setAllFolded(false);
}}
>
{t("tree.groupUnfoldAll")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
testId="workspace-group-delete"
onSelect={() => {
const g = groupMenu.group;
setGroupMenu(null);
void removeGroup(g);
}}
>
{t("tree.groupDelete")}
</MenuItem>
</ContextMenu>
)}
<Suspense fallback={null}>
{destructive && (
<DestructiveLaneModal
@@ -460,8 +705,10 @@ export function WorkspaceTree() {
onClose={() => {
setAddOpen(false);
setAddPrefill(null);
setAddToGroup(null);
}}
onAdded={(lane) => {
if (addToGroup) assignGroup(lane.id, addToGroup);
void refreshLanes();
select(lane.id);
}}
@@ -472,9 +719,167 @@ export function WorkspaceTree() {
);
}
/** What a group header's drag carries: the group's id. */
const GROUP_DRAG_TYPE = "application/x-agenthub-workspace-group";
const isGroupDrag = (e: { dataTransfer: DataTransfer | null }) =>
!!e.dataTransfer?.types?.includes(GROUP_DRAG_TYPE);
// needsYou outranks an error, an error a running agent; idle/active say nothing.
const URGENCY: Record<string, number> = { needsYou: 3, error: 2, running: 1 };
/** The most urgent of `lanes` by its dot, or null when all are at rest. */
function worstOf(lanes: Lane[]): Lane | null {
let best: Lane | null = null;
let top = 0;
for (const l of lanes) {
const u = URGENCY[workspaceDotLabel(l)] ?? 0;
if (u > top) [best, top] = [l, u];
}
return best;
}
/** A workspace's owner group: its user id, or "none". */
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
/**
* A section's header: a user group (foldable, menu, inline rename) or, with
* `group` null, "Ungrouped". Both take a dropped workspace (`data-group-drop`).
*/
function GroupHeader({
group,
count,
worst,
holdsSelected,
folded,
renaming,
first,
onToggle,
onMenu,
onRenameEnd,
onGroupDrop,
}: {
group: WorkspaceGroup | null;
count: number;
/** The most urgent workspace inside, drawn as its dot while folded. */
worst: Lane | null;
/** The open workspace is in this group (marked while folded). */
holdsSelected: boolean;
folded: boolean;
renaming: boolean;
first: boolean;
onToggle: () => void;
onMenu: (x: number, y: number) => void;
/** The typed name, or null when the edit was cancelled. */
onRenameEnd: (name: string | null) => void;
/** A group header (by id) dropped here: it goes before this group, or last on "Ungrouped". */
onGroupDrop: (draggedId: string) => void;
}) {
const { t } = useTranslation("lanes");
const [draft, setDraft] = useState(group?.name ?? "");
const [dropOver, setDropOver] = useState(false);
useEffect(() => {
if (renaming) setDraft(group?.name ?? "");
}, [renaming, group?.name]);
const label = group ? group.name : t("tree.ungrouped");
return (
<div
data-group-drop={group?.id ?? ""}
data-testid={`workspace-group-${group?.id ?? "none"}`}
onContextMenu={(e) => {
if (!group) return;
e.preventDefault();
onMenu(e.clientX, e.clientY);
}}
// A group (never "Ungrouped") is dragged by its header to reorder.
draggable={!!group && !renaming}
onDragStart={(e) => {
if (!group) return;
e.dataTransfer.setData(GROUP_DRAG_TYPE, group.id);
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
if (!isGroupDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDropOver(true);
}}
onDragLeave={() => setDropOver(false)}
onDrop={(e) => {
setDropOver(false);
if (!isGroupDrag(e)) return;
e.preventDefault();
const id = e.dataTransfer.getData(GROUP_DRAG_TYPE);
if (id) onGroupDrop(id);
}}
className={`group/gh flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-semibold text-fg-muted ${first ? "" : "mt-1.5"} ${dropOver ? "shadow-[inset_0_2px_0_rgb(var(--accent))]" : ""}`}
>
{renaming && group ? (
<input
autoFocus
value={draft}
maxLength={60}
aria-label={t("tree.groupNameLabel")}
onChange={(e) => setDraft(e.target.value)}
onFocus={(e) => e.currentTarget.select()}
onBlur={() => onRenameEnd(draft)}
onKeyDown={(e) => {
if (e.key === "Enter") onRenameEnd(draft);
else if (e.key === "Escape") onRenameEnd(null);
}}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-1 py-0.5 text-[11px] font-normal text-fg-primary"
/>
) : (
<button
type="button"
aria-expanded={group ? !folded : undefined}
disabled={!group}
title={group ? t("tree.groupToggle", { name: label }) : undefined}
onClick={onToggle}
className="flex min-w-0 flex-1 items-center gap-1 text-left hover:text-fg-primary disabled:hover:text-fg-muted"
>
{group ? (
folded ? (
<ChevronRight className="h-3 w-3 shrink-0" />
) : (
<ChevronDown className="h-3 w-3 shrink-0" />
)
) : (
<span className="w-3 shrink-0" />
)}
<span
className={`truncate uppercase tracking-wide ${folded && holdsSelected ? "text-accent" : ""}`}
>
{label}
</span>
<span className="ml-1 shrink-0 font-normal tabular-nums">{count}</span>
{folded && worst && (
<span
data-testid={`workspace-group-state-${group?.id ?? "none"}`}
className="ml-1 flex shrink-0 items-center"
>
<span aria-hidden className={`h-1.5 w-1.5 rounded-full ${workspaceDot(worst)}`} />
<span className="sr-only">{t(`row.state.${workspaceDotLabel(worst)}`)}</span>
</span>
)}
</button>
)}
{group && !renaming && (
<button
type="button"
aria-label={t("tree.groupMenu")}
title={t("tree.groupMenu")}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
onMenu(r.left, r.bottom);
}}
className="rounded p-0.5 opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover/gh:opacity-100"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
/** Where a dragged workspace would land: a line on its parent's top edge. */
function DropLine() {
return (
@@ -312,6 +312,91 @@ describe("UpdateNotifier — closing vs skipping", () => {
});
});
describe("UpdateNotifier — hidden while it runs", () => {
it("keeps a chip with the chain's step in sight and re-opens from it", async () => {
const PLUGIN: UpdateStatusPayload = {
...BASE_STATUS,
situation: "plugin_managed",
install_kind: "plugin",
plugin_marketplace: "sg",
plugin_name: "agenthub",
commits_behind: 0,
local_version: "1.5.0",
release_check: "ok",
latest_release: "v1.6.0",
};
statusMock.mockResolvedValue(PLUGIN);
applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" });
render(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
await screen.findAllByText(i18n.t("updates:pluginUpdating"));
statusMock.mockResolvedValue({
...PLUGIN,
plugin_update_progress: {
step: "plugin_update",
index: 2,
total: 3,
started_at: new Date().toISOString(),
},
});
const step = i18n.t("updates:pluginStep.plugin_update", { index: 2, total: 3 });
expect(await screen.findByTestId("update-step", {}, { timeout: 4000 })).toHaveTextContent(step);
await userEvent.keyboard("{Escape}");
const chip = await screen.findByTestId("update-chip");
expect(chip).toHaveTextContent(i18n.t("updates:chipTitle", { version: "v1.6.0" }));
expect(chip).toHaveTextContent(step);
await userEvent.click(chip);
expect(
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }))
).toBeInTheDocument();
expect(screen.queryByTestId("update-chip")).toBeNull();
});
it("adopts a chain the server reports as running, and keeps it once the update is no longer offered", async () => {
const running: UpdateStatusPayload = {
...BASE_STATUS,
situation: "plugin_managed",
install_kind: "plugin",
plugin_marketplace: "sg",
plugin_name: "agenthub",
commits_behind: 0,
local_version: "1.5.0",
release_check: "ok",
latest_release: "v1.6.0",
// The new version is already installed: the status no longer offers it.
update_available: false,
plugin_update_progress: {
step: "bootstrap",
index: 3,
total: 3,
started_at: new Date().toISOString(),
},
};
statusMock.mockResolvedValue(running);
render(<UpdateNotifier />);
const step = i18n.t("updates:pluginStep.bootstrap", { index: 3, total: 3 });
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
expect(screen.queryByText(i18n.t("updates:updateNow"))).toBeNull();
await userEvent.keyboard("{Escape}");
const chip = await screen.findByTestId("update-chip");
expect(chip).toHaveTextContent(step);
await userEvent.click(chip);
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
});
it("shows no chip when nothing is running", async () => {
render(<UpdateNotifier />);
await screen.findByText(i18n.t("updates:title"));
await userEvent.keyboard("{Escape}");
await waitFor(() =>
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
);
expect(screen.queryByTestId("update-chip")).toBeNull();
});
});
describe("UpdateNotifier — Auto fix", () => {
it("offers no Not now button, and Auto fix only once an update failed", async () => {
applyMock.mockRejectedValue(new Error("Command failed: git merge --ff-only v2.8.0"));
@@ -6,7 +6,9 @@
* workspace's OWN sessions only, a session's state dot, rename and archive,
* selection
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
* search, the row menu's items by kind and tool, and the status dot.
* search, the row menu's items by kind and tool, the status dot, and the
* user's own groups (menu, Alt+arrow past a group's edge, a folded group's
* state dot, dragging a header to reorder, fold all).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -91,6 +93,20 @@ vi.mock("../../lib/api", async (importOriginal) => {
};
});
// The Add modal reports the workspace it made (id 2 here); the tree does the rest.
vi.mock("../lanes/AddLaneModal", () => ({
AddLaneModal: ({ onAdded, onClose }: { onAdded: (l: Lane) => void; onClose: () => void }) => (
<button
type="button"
data-testid="stub-add-lane"
onClick={() => {
onAdded(lane(2, "/r/sub"));
onClose();
}}
/>
),
}));
import { api } from "../../lib/api";
import { WorkspaceTree } from "../WorkspaceTree";
import { workspaceDot } from "../lanes/WorkspaceRow";
@@ -576,6 +592,185 @@ describe("WorkspaceTree", () => {
menu = screen.getByTestId("workspace-row-menu-3");
expect(within(menu).getByText("Reset worktree…")).toBeInTheDocument();
});
it("groups workspaces under named, foldable headers, ungrouped last", async () => {
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-row-1");
// No group yet: no headers at all.
expect(screen.queryByTestId("workspace-group-none")).toBeNull();
// A new group from the row menu holds that workspace and asks for a name.
await user.click(screen.getByTestId("workspace-menu-3"));
await user.click(screen.getByTestId("workspace-move-group-new"));
const name = await screen.findByRole("textbox", { name: "Group name" });
await user.clear(name);
await user.type(name, "Backend{Enter}");
const stored = JSON.parse(localStorage.getItem("agenthub.workspace.groups")!);
expect(stored.groups).toEqual([expect.objectContaining({ name: "Backend" })]);
const gid = stored.groups[0].id as string;
expect(stored.of).toEqual({ 3: gid });
await waitFor(() =>
expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-1", "workspace-row-2"])
);
expect(screen.getByTestId(`workspace-group-${gid}`)).toHaveTextContent("Backend1");
expect(screen.getByTestId("workspace-group-none")).toHaveTextContent("Ungrouped2");
// Another workspace joins it from its menu.
await user.click(screen.getByTestId("workspace-menu-2"));
await user.click(screen.getByTestId(`workspace-move-group-${gid}`));
await waitFor(() =>
expect(rowIds()).toEqual(["workspace-row-2", "workspace-row-3", "workspace-row-1"])
);
// Folding hides its rows; the ungrouped one stays.
await user.click(
within(screen.getByTestId(`workspace-group-${gid}`)).getByRole("button", {
expanded: true,
})
);
expect(rowIds()).toEqual(["workspace-row-1"]);
// Deleting the group only ungroups: every workspace is still listed.
fireEvent.contextMenu(screen.getByTestId(`workspace-group-${gid}`));
await user.click(screen.getByTestId("workspace-group-delete"));
await waitFor(() => expect(rowIds()).toHaveLength(3));
expect(screen.queryByTestId("workspace-group-none")).toBeNull();
expect(api.lanes.update).not.toHaveBeenCalled();
});
it("shows a new, empty group to be named, above the ungrouped workspaces", async () => {
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-row-1");
await user.click(screen.getByTestId("workspace-tree-new-group"));
const name = await screen.findByRole("textbox", { name: "Group name" });
await user.clear(name);
await user.type(name, "Later{Enter}");
const gid = JSON.parse(localStorage.getItem("agenthub.workspace.groups")!).groups[0].id;
const header = screen.getByTestId(`workspace-group-${gid}`);
expect(header).toHaveTextContent("Later0");
expect(
header.compareDocumentPosition(screen.getByTestId("workspace-group-none")) &
Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
expect(rowIds()).toHaveLength(3);
});
});
describe("WorkspaceTree groups", () => {
// Groups A = [1], B = [3]; 2 ungrouped. Stored order 1, 2, 3.
const seed = (extra: { folded?: boolean } = {}) =>
localStorage.setItem(
"agenthub.workspace.groups",
JSON.stringify({
groups: [
{ id: "ga", name: "Alpha", ...extra },
{ id: "gb", name: "Beta" },
],
of: { 1: "ga", 3: "gb" },
})
);
const stored = () => JSON.parse(localStorage.getItem("agenthub.workspace.groups")!);
it("moves a workspace into the next group with Alt+arrow past its edge", async () => {
seed();
const user = userEvent.setup();
renderTree();
await waitFor(() =>
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-3", "workspace-row-2"])
);
// The last of Alpha, Alt+↓: the first of Beta.
screen.getByTestId("workspace-open-1").focus();
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
expect(stored().of).toEqual({ 1: "gb", 3: "gb" });
await waitFor(() =>
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-3", "workspace-row-2"])
);
expect(screen.getByTestId("workspace-group-gb")).toHaveTextContent("Beta2");
// The last of Beta, Alt+↓: out into the ungrouped ones.
screen.getByTestId("workspace-open-3").focus();
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
expect(stored().of).toEqual({ 1: "gb" });
expect(api.lanes.update).not.toHaveBeenCalled();
});
it("shows a folded group's most urgent state and marks the open workspace", async () => {
lanes[0] = lane(1, "/r", { title: "Repo", status: "running" });
seed({ folded: true });
renderTree("/workspace?lane=1");
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-2"]));
const state = screen.getByTestId("workspace-group-state-ga");
expect(state).toHaveTextContent("running");
expect(within(screen.getByTestId("workspace-group-ga")).getByText("Alpha")).toHaveClass(
"text-accent"
);
// An open group shows its rows instead.
expect(screen.queryByTestId("workspace-group-state-gb")).toBeNull();
});
it("reorders groups by dragging a header onto another, last onto Ungrouped", async () => {
seed();
renderTree();
await screen.findByTestId("workspace-group-gb");
const data = new Map<string, string>();
const dataTransfer = {
get types() {
return [...data.keys()];
},
setData: (k: string, v: string) => data.set(k, v),
getData: (k: string) => data.get(k) ?? "",
dropEffect: "move",
effectAllowed: "move",
};
fireEvent.dragStart(screen.getByTestId("workspace-group-gb"), { dataTransfer });
fireEvent.dragOver(screen.getByTestId("workspace-group-ga"), { dataTransfer });
fireEvent.drop(screen.getByTestId("workspace-group-ga"), { dataTransfer });
expect(stored().groups.map((g: { id: string }) => g.id)).toEqual(["gb", "ga"]);
await waitFor(() =>
expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-1", "workspace-row-2"])
);
data.clear();
fireEvent.dragStart(screen.getByTestId("workspace-group-gb"), { dataTransfer });
fireEvent.drop(screen.getByTestId("workspace-group-none"), { dataTransfer });
expect(stored().groups.map((g: { id: string }) => g.id)).toEqual(["ga", "gb"]);
// A header drag never moves a workspace.
expect(stored().of).toEqual({ 1: "ga", 3: "gb" });
});
it("puts a workspace added from a group's menu into that group", async () => {
seed();
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-group-gb");
fireEvent.contextMenu(screen.getByTestId("workspace-group-gb"));
await user.click(screen.getByTestId("workspace-group-add"));
await user.click(await screen.findByTestId("stub-add-lane"));
expect(stored().of).toEqual({ 1: "ga", 2: "gb", 3: "gb" });
// The header's own Add belongs to no group: what it adds stays where it is.
act(() =>
localStorage.setItem(
"agenthub.workspace.groups",
JSON.stringify({ ...stored(), of: { 1: "ga" } })
)
);
await user.click(screen.getByTestId("workspace-tree-add"));
await user.click(await screen.findByTestId("stub-add-lane"));
expect(stored().of).toEqual({ 1: "ga" });
});
it("folds and unfolds every group from a header's menu", async () => {
seed();
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-group-ga");
fireEvent.contextMenu(screen.getByTestId("workspace-group-ga"));
await user.click(screen.getByTestId("workspace-group-fold-all"));
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-2"]));
fireEvent.contextMenu(screen.getByTestId("workspace-group-gb"));
await user.click(screen.getByTestId("workspace-group-unfold-all"));
await waitFor(() => expect(rowIds()).toHaveLength(3));
});
});
describe("workspaceDot", () => {
+78 -12
View File
@@ -7,6 +7,10 @@
* folder's account with that account's own SSH key / HTTPS login — and opens
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
* A running clone shows `CloneProgress` and never holds the page: closing the
* dialog only hides it behind a small progress chip (click to reopen), the
* request keeps going, and its outcome arrives as a toast — a failure is kept
* for the next open so a login row is still there to fill in.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -18,6 +22,8 @@ import { api } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useDialog } from "../../hooks/useDialog";
import { cloneDirName } from "./AddLaneModal";
import { CloneProgress, newCloneToken } from "./CloneProgress";
import { toast } from "../../lib/toast";
type Login = { host: string; username: string; token: string; remember: boolean };
@@ -26,12 +32,15 @@ export function CloneHereDialog({
parent,
onClose,
onCloned,
onReopen,
}: {
open: boolean;
/** The absolute folder the clone goes into. */
parent: string;
onClose: () => void;
onCloned: (lane: Lane, path: string) => void;
/** Show the dialog again from the background chip. */
onReopen: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
@@ -42,10 +51,23 @@ export function CloneHereDialog({
const [sshRefused, setSshRefused] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useDialog(open, panelRef, busy ? () => {} : onClose);
const [token, setToken] = useState<string | null>(null);
/** Where the running clone goes (the open folder may change meanwhile). */
const [target, setTarget] = useState("");
// Read by the request's own continuation and by the reset below.
const openRef = useRef(open);
openRef.current = open;
const busyRef = useRef(false);
/** A clone failed while hidden: the next open shows that, not a blank form. */
const keepRef = useRef(false);
useDialog(open, panelRef, onClose);
useEffect(() => {
if (!open) return;
if (!open || busyRef.current) return;
if (keepRef.current) {
keepRef.current = false;
return;
}
setUrl("");
setName("");
setLogin(null);
@@ -54,14 +76,39 @@ export function CloneHereDialog({
setError(null);
}, [open, parent]);
if (!open) return null;
const folder = name.trim() || cloneDirName(url);
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
const into = busy ? target : `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
if (!open) {
if (!busy || !token) return null;
return createPortal(
<button
type="button"
data-testid="fx-clone-chip"
onClick={onReopen}
title={into}
className="fixed bottom-10 right-4 z-[60] w-72 space-y-1 rounded-lg border border-border bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
>
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
<FolderGit2 className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
{t("clone.cloning")} {into.split("/").pop()}
</span>
</span>
<CloneProgress token={token} />
</button>,
document.body
);
}
const submit = async () => {
const u = url.trim();
if (!u || busy) return;
const progressToken = newCloneToken();
busyRef.current = true;
setBusy(true);
setToken(progressToken);
setTarget(into);
setError(null);
setSshRefused(false);
try {
@@ -69,6 +116,7 @@ export function CloneHereDialog({
url: u,
parent,
name: name.trim() || undefined,
progressToken,
...(login?.username.trim() && login.token
? {
credentials: {
@@ -79,12 +127,16 @@ export function CloneHereDialog({
}
: {}),
});
busyRef.current = false;
setBusy(false);
setToken(null);
onCloned(result.lane, result.path);
} catch (err) {
const e = err as { code?: string; auth?: { host?: string }; message?: string };
const host = e.auth?.host || u;
let message: string;
if (e.code === "ECREDENTIALS") {
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
message = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
setLogin((l) => ({
host,
username: l?.username ?? "",
@@ -93,18 +145,25 @@ export function CloneHereDialog({
}));
} else if (e.code === "ESSHAUTH") {
setSshRefused(true);
setError(t("clone.sshRefused", { host }));
message = t("clone.sshRefused", { host });
} else {
setError(err instanceof Error ? err.message : String(err));
message = err instanceof Error ? err.message : String(err);
}
setError(message);
busyRef.current = false;
setBusy(false);
setToken(null);
if (!openRef.current) {
keepRef.current = true;
toast.error(message);
}
}
};
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
onClick={busy ? undefined : onClose}
onClick={onClose}
// A portal still bubbles through React: keep keys away from the File
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
onKeyDown={(e) => e.stopPropagation()}
@@ -119,7 +178,10 @@ export function CloneHereDialog({
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
onClick={(e) => e.stopPropagation()}
>
<h2 id={titleId} className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary">
<h2
id={titleId}
className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary"
>
<FolderGit2 className="h-4 w-4" />
{t("fx.cloneRepo")}
</h2>
@@ -162,7 +224,10 @@ export function CloneHereDialog({
</p>
</div>
{login && (
<div data-testid="fx-clone-login" className="space-y-2 rounded-md border border-border p-2">
<div
data-testid="fx-clone-login"
className="space-y-2 rounded-md border border-border p-2"
>
<input
aria-label={t("clone.username")}
placeholder={t("clone.username")}
@@ -193,6 +258,7 @@ export function CloneHereDialog({
</label>
</div>
)}
{busy && token && <CloneProgress token={token} />}
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}{" "}
@@ -204,8 +270,8 @@ export function CloneHereDialog({
</p>
)}
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} disabled={busy} className="btn-ghost text-xs">
{t("fx.cancel")}
<button type="button" onClick={onClose} className="btn-ghost text-xs">
{busy ? t("clone.background") : t("fx.cancel")}
</button>
<button
type="submit"
+4 -2
View File
@@ -85,6 +85,7 @@ import {
type FsTrashItem,
type FsHome,
type FsPlace,
type GitSource,
isHomePlace,
} from "../../lib/api";
import type { Lane } from "../../lib/types";
@@ -283,7 +284,7 @@ export function FileBrowser({
/** A home to open on (`me`, or another user's for an admin). */
initialHome?: string | null;
session?: string | null;
source?: number | null;
source?: GitSource | null;
initialPath?: string;
/** Called after anything changed files, so a tree elsewhere can re-read. */
onChanged?: () => void;
@@ -974,6 +975,7 @@ export function FileBrowser({
open={cloning}
parent={cloneParent}
onClose={() => setCloning(false)}
onReopen={() => setCloning(true)}
onCloned={(lane, at) => {
setCloning(false);
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
@@ -1881,7 +1883,7 @@ export function FileExplorer({
onClose: () => void;
laneId: number;
session?: string | null;
source?: number | null;
source?: GitSource | null;
initialPath?: string;
onChanged?: () => void;
}) {
@@ -103,7 +103,7 @@ import {
Trash2,
UnfoldVertical,
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { api, gitTarget, type GitSource } from "../../lib/api";
import { usePermissions } from "../AuthGate";
import { pasteToLane } from "../../lib/panePaste";
import { readMarkdownPreview } from "../../lib/markdownView";
@@ -827,7 +827,7 @@ export function LaneChangesPanel({
session?: string;
/** Show and act on that extra folder's repo instead (`lane_sources` id);
* wins over `session`, and is keyed on like it. */
source?: number;
source?: GitSource;
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
onNewLane?: (branch?: string) => void;
/** The panel has the whole width: the file tree becomes a sidebar and a
@@ -15,7 +15,7 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp } from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { api, gitTarget, type GitSource } from "../../lib/api";
import {
applyChoices,
parseConflicts,
@@ -47,7 +47,7 @@ export function LaneConflictView({
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
source?: GitSource;
path: string;
/** The file left the conflict (resolved, or a whole side taken). */
onResolved: () => void;
+2 -2
View File
@@ -38,7 +38,7 @@ import {
Trash2,
Upload,
} from "lucide-react";
import { api, gitTarget, type GitAuthNeeded } from "../../lib/api";
import { api, gitTarget, type GitSource, type GitAuthNeeded } from "../../lib/api";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
@@ -98,7 +98,7 @@ export function LaneGitBar({
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
source?: GitSource;
git: LaneGitFacts;
/** Rendered at the start of the action row: the header's branch line, so
* the facts and the buttons that act on them read as one row. */
@@ -19,7 +19,7 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { GitBranch } from "lucide-react";
import { LaneGitBar } from "./LaneGitBar";
import { api, gitTarget } from "../../lib/api";
import { api, gitTarget, type GitSource } from "../../lib/api";
import { keepIfSame } from "../../lib/keepIfSame";
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
@@ -37,8 +37,8 @@ export function useLaneGitFacts(
laneId: number,
/** Read that session's own worktree instead (`?session=`). */
session?: string,
/** Read that extra folder's repo instead (`?source=`). */
source?: number
/** Read that extra folder's repo (`?source=`, a number) or that inner repo (`?repo=`, a string) instead. */
source?: GitSource
): [LaneGitFacts | null, () => void] {
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
// Bumped by the git action bar: a commit or a pull changes every fact on
@@ -329,7 +329,7 @@ export function LaneGitRailCounts({
}: {
laneId: number;
session?: string;
source?: number;
source?: GitSource;
/** Opens the Changes panel from the file list's "… more files". */
onShowAll?: () => void;
}) {
@@ -349,7 +349,7 @@ export function LaneGitHeader({
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
source?: GitSource;
git: LaneGitFacts | null;
/** Called after a git bar action: the facts AND the panel's diff moved. */
onChanged: () => void;
@@ -3,7 +3,9 @@
* the pointer (right-click) or under the row's `⋯` button. Every item is an
* existing call: Rename (the row's inline field), New workspace here (the
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* managed lane. The server's guards are the real ones; the menu only hides
* managed lane — plus Move to group (the sidebar's own groups,
* `lib/workspaceGroups.ts`: one of them, a new one, or out of its group). The
* server's guards are the real ones; the menu only hides
* what the lane's kind or tool makes meaningless. The shell (position,
* Escape / outside-click close) is `ContextMenu`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -11,7 +13,9 @@
import { useTranslation } from "react-i18next";
import type { Lane } from "../../lib/types";
import { Check } from "lucide-react";
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
import type { WorkspaceGroup } from "../../lib/workspaceGroups";
import { copyText } from "../../lib/clipboard";
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
@@ -22,6 +26,9 @@ export function WorkspaceRowMenu({
y,
onClose,
onAction,
groups = [],
groupId = null,
onGroup,
}: {
lane: Lane;
x: number;
@@ -29,6 +36,11 @@ export function WorkspaceRowMenu({
onClose: () => void;
/** Items the tree itself carries out (rename field, modals). */
onAction: (action: WorkspaceMenuAction) => void;
/** The sidebar's groups and the one this workspace is in. */
groups?: WorkspaceGroup[];
groupId?: string | null;
/** Move it: to a group's id, out of its group (null), or into a new group ("new"). */
onGroup?: (target: string | null | "new") => void;
}) {
const { t } = useTranslation("lanes");
return (
@@ -43,6 +55,35 @@ export function WorkspaceRowMenu({
>
{t("menu.copyPath")}
</MenuItem>
{onGroup && (
<>
<MenuSeparator />
<div className="px-2 pb-0.5 pt-1 text-[10px] font-semibold uppercase tracking-wide text-fg-muted">
{t("menu.moveToGroup")}
</div>
{groups.map((g) => (
<MenuItem
key={g.id}
testId={`workspace-move-group-${g.id}`}
disabled={g.id === groupId}
onSelect={() => onGroup(g.id)}
>
<Check className={`h-3 w-3 shrink-0 ${g.id === groupId ? "" : "invisible"}`} />
<span className="truncate">{g.name}</span>
</MenuItem>
))}
<MenuItem testId="workspace-move-group-new" onSelect={() => onGroup("new")}>
<span className="w-3 shrink-0" />
{t("menu.newGroupWith")}
</MenuItem>
{groupId && (
<MenuItem testId="workspace-move-group-none" onSelect={() => onGroup(null)}>
<span className="w-3 shrink-0" />
{t("menu.removeFromGroup")}
</MenuItem>
)}
</>
)}
<MenuSeparator />
{lane.kind === "managed" && (
<MenuItem danger onSelect={() => onAction("reset")}>
@@ -30,6 +30,7 @@ vi.mock("../../../lib/api", () => ({
lanes: {
list: vi.fn(),
clone: vi.fn(),
cloneProgress: vi.fn(),
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
},
files: {
@@ -553,12 +554,72 @@ describe("FileExplorer — New → Clone repository", () => {
url: "git@host:o/repo.git",
parent: "/w/one/src",
name: undefined,
progressToken: expect.stringMatching(/^[0-9a-f]{32}$/),
})
);
expect(toast.success).toHaveBeenCalled();
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
});
it("shows progress and lets the page go on while the clone runs", async () => {
let finish: (v: unknown) => void = () => {};
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((r) => (finish = r)) as never);
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
state: "running",
position: null,
running: 1,
waiting: 0,
limit: 2,
phase: "receiving",
label: null,
percent: 45,
detail: "450/1000",
} as never);
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
expect(await within(dialog).findByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
await user.click(within(dialog).getByRole("button", { name: "Run in background" }));
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
const chip = await screen.findByTestId("fx-clone-chip");
expect(within(chip).getByText(/repo/)).toBeInTheDocument();
await user.click(chip);
expect(screen.getByTestId("fx-clone-dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
finish({
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
created: true,
path: "/w/one/repo",
});
await waitFor(() => expect(toast.success).toHaveBeenCalled());
expect(screen.queryByTestId("fx-clone-chip")).toBeNull();
});
it("toasts a failure that lands while hidden and keeps it for the next open", async () => {
let fail: (e: unknown) => void = () => {};
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((_, r) => (fail = r)) as never);
vi.mocked(api.lanes.cloneProgress).mockRejectedValue(new Error("404"));
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
await user.keyboard("{Escape}");
fail(
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
);
await waitFor(() => expect(toast.error).toHaveBeenCalled());
const again = await openClone(user);
expect(within(again).getByTestId("fx-clone-login")).toBeInTheDocument();
expect(within(again).getByLabelText("Repository URL")).toHaveValue(
"https://git.example/o/r.git"
);
});
it("asks for a login on ECREDENTIALS and sends it once", async () => {
vi.mocked(api.lanes.clone)
.mockRejectedValueOnce(
+21 -1
View File
@@ -18,7 +18,10 @@
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
* reads them when the prompt is sent — an image as its bare path, which claude
* shows as `[Image #n]` like a native drop; a browser on the dashboard's own
* shows as `[Image #n]` like a native drop — except a PASTED image in a
* `claude` pane of a local-mode dashboard opened on its own machine, which is
* sent as ^V so `claude` reads the desktop clipboard itself, uploading nothing
* (`lib/nativePaste.ts`, a Settings switch); a browser on the dashboard's own
* machine pastes a file manager's files (dropped, or copied: `file://` text)
* by their own paths instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
@@ -75,6 +78,8 @@ import {
sameFilesHere,
} from "../../lib/promptAttach";
import { attachImeInput } from "../../lib/imeInput";
import { readNativePaste } from "../../lib/nativePaste";
import { usePermissions } from "../AuthGate";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
@@ -239,6 +244,7 @@ export const TerminalView = memo(function TerminalView({
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
const ctrlVRef = useRef(false);
const { local: localMode } = usePermissions();
const onDrop = (e: DragEvent) => {
if (hasText(e)) {
@@ -279,6 +285,20 @@ export const TerminalView = memo(function TerminalView({
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
e.preventDefault();
e.stopPropagation();
// On the dashboard's own machine the browser's clipboard IS the pane's:
// `claude` reads a pasted image itself on ^V, as in a native terminal —
// nothing uploaded (lib/nativePaste.ts; off for an SSH tunnel's browser).
const nativeImage =
tool === "claude" &&
localMode &&
isLocalBrowser() &&
readNativePaste() &&
files.length > 0 &&
files.every((f) => f.type.startsWith("image/"));
if (nativeImage) {
termRef.current?.input("\x16", true);
return;
}
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files));
else termRef.current?.input("\x16", true);
};
@@ -590,6 +590,37 @@ describe("TerminalView", () => {
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
});
it("hands a local claude pane's pasted image to claude as ^V, uploading nothing", () => {
dropFileMock.mockReset();
inputMock.mockClear();
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
expect(inputMock).toHaveBeenCalledWith("\x16", true);
expect(dropFileMock).not.toHaveBeenCalled();
expect(pasteMock).not.toHaveBeenCalled();
});
it("uploads a claude pane's pasted image once native paste is turned off", async () => {
localStorage.setItem("agenthub.terminal.nativePaste", "off");
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
inputMock.mockClear();
try {
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(img, "r1"));
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
} finally {
localStorage.removeItem("agenthub.terminal.nativePaste");
}
});
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
// pasted like any browser paste: sending ^V for it pasted nothing.
@@ -4,7 +4,9 @@
* one card holds one subject: **Text & fonts** (`#fonts`) — the interface
* language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
* issue #79), the terminal font and its size (`lib/terminalFont.ts`,
* `#terminal-font`, with a preview line); **Sessions** (`#sessions-view`) —
* `#terminal-font`, with a preview line), and — local mode only — whether a
* pasted image reaches a `claude` pane natively (`lib/nativePaste.ts`,
* `#native-paste`, this browser only); **Sessions** (`#sessions-view`) —
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
* `#workspace-tabs`), and whether a Markdown file opens as Preview (the
@@ -39,6 +41,8 @@ import {
} from "../../lib/terminalFont";
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
import { readMarkdownPreview, writeMarkdownPreview } from "../../lib/markdownView";
import { readNativePaste, writeNativePaste } from "../../lib/nativePaste";
import { usePermissions } from "../AuthGate";
import {
TERMINAL_FONT_SIZES,
UI_FONT_SIZES,
@@ -100,6 +104,8 @@ export function AppearanceSettings() {
const [uiSize, setUiSize] = useState(readUiFontSize);
const [allTabs, setAllTabs] = useState(readAllTabs);
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
const [nativePaste, setNativePaste] = useState(readNativePaste);
const { local } = usePermissions();
const pickTermFont = (font: TerminalFont) => {
writeTerminalFont(font);
setTermFont(font);
@@ -209,6 +215,18 @@ export function AppearanceSettings() {
>
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
</p>
{local && (
<SettingsSwitchRow
id="native-paste"
label={t("nativePaste.label")}
description={t("nativePaste.description")}
checked={nativePaste}
onChange={(on) => {
writeNativePaste(on);
setNativePaste(on);
}}
/>
)}
</SettingsSection>
<SettingsSection
@@ -0,0 +1,26 @@
/**
* @file ChangesPanelSettings — Settings → Git → Changes panel: whether a
* workspace folder that holds several git repositories shows each of them as
* its own section in the Changes panel (off by default: only the folder's own
* git). Writes the account preference in `lib/innerReposPref.ts`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { useInnerReposMode, writeInnerReposMode } from "../../lib/innerReposPref";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export function ChangesPanelSettings() {
const { t } = useTranslation("settings");
const mode = useInnerReposMode();
return (
<SettingsSection title={t("changesPanel.title")} description={t("changesPanel.description")}>
<SettingsSwitchRow
label={t("changesPanel.innerRepos")}
description={t("changesPanel.innerReposDesc")}
checked={mode === "all"}
onChange={(on) => writeInnerReposMode(on ? "all" : "own")}
/>
</SettingsSection>
);
}
@@ -14,7 +14,9 @@
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
* each tied to the one key it uses (the SSH part is split into tabs: one per
* kind of host, the extra keys, the other hosts) — and, for
* kind of host, then the other hosts; a named key is a card of its own under
* its type's default one, with the same steps, and "Add another key" makes
* the next one below) — and, for
* a user with their own Linux account (S3), their own HTTPS
* logins. The host account's HTTPS logins are the shared Git credentials
* table above it, so this lists them only for a member. The private key and
@@ -24,17 +26,7 @@
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Cloud,
Copy,
GitBranch,
KeyRound,
KeySquare,
Plus,
RefreshCw,
Server,
Trash2,
} from "lucide-react";
import { Cloud, Copy, GitBranch, KeyRound, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
import { api, type GitIdentity } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
@@ -96,17 +88,19 @@ function Code({ children }: { children: ReactNode }) {
}
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
type SshUser = NonNullable<GitIdentity["sshUsers"]>[number];
/**
* One kind of git host and the key it takes: no key → a Generate button;
* a key → step 1 (copy its public half) followed by the host's own steps.
* One SSH key and the steps of the host it is for: no key → a Generate
* button; a key → step 1 (copy its public half) followed by the host's own
* steps. The default key of a type and every named one share it.
*/
function KeyCard({
title,
sub,
type,
label,
k,
id,
writable,
busy,
onGenerate,
onRemove,
@@ -114,12 +108,13 @@ function KeyCard({
}: {
title: string;
sub: string;
type: "ed25519" | "rsa";
/** What names the public key field ("RSA", "ED25519" or the key's name). */
label: string;
k: Key | undefined;
id: GitIdentity;
writable: boolean;
busy: boolean;
onGenerate: (type: "ed25519" | "rsa") => Promise<void>;
onRemove: (type: "ed25519" | "rsa") => Promise<void>;
onGenerate: () => Promise<void>;
onRemove: () => Promise<void>;
children: ReactNode;
}) {
const { t } = useTranslation("settings");
@@ -130,23 +125,19 @@ function KeyCard({
<div className="text-xs font-medium text-fg-primary">{title}</div>
<div className="text-[10px] text-fg-muted">{sub}</div>
</div>
{id.writable && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => void onGenerate(type)}
>
{writable && (
<button type="button" className={BTN} disabled={busy} onClick={() => void onGenerate()}>
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
</button>
)}
{k && id.writable && (
{k && writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
onClick={() => void onRemove(type)}
aria-label={k.name ? t("gitIdentity.removeKeyNamed", { name: k.name }) : undefined}
onClick={() => void onRemove()}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.remove")}
@@ -165,9 +156,9 @@ function KeyCard({
</div>
<textarea
readOnly
aria-label={`${t("gitIdentity.publicKey")} ${type === "rsa" ? "RSA" : "ED25519"}`}
aria-label={`${t("gitIdentity.publicKey")} ${label}`}
value={k.publicKey}
rows={type === "rsa" ? 3 : 2}
rows={k.type === "rsa" ? 3 : 2}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
/>
@@ -182,6 +173,104 @@ function KeyCard({
);
}
/**
* Step 3 of an AWS key: the SSH key ID AWS shows for it, in one region. The
* default key's login is the region's plain host; a named key's is saved
* under an account name (its own name unless another is typed), tied to it.
*/
function AwsLoginForm({
busy,
named,
onSave,
}: {
busy: boolean;
/** The named key's name; undefined for the default key. */
named?: string;
onSave: (v: { keyId: string; region: string; alias: string }) => Promise<boolean>;
}) {
const { t } = useTranslation("settings");
const [v, setV] = useState({ keyId: "", region: "ap-southeast-1", alias: "" });
return (
<form
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault();
void onSave(v).then((ok) => ok && setV({ ...v, keyId: "", alias: "" }));
}}
>
<input
aria-label={t("gitIdentity.awsKeyId")}
placeholder="APKA…"
autoComplete="off"
value={v.keyId}
onChange={(e) => setV({ ...v, keyId: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.awsRegion")}
value={v.region}
onChange={(e) => setV({ ...v, region: e.target.value })}
className="select !py-1 !text-xs"
>
{AWS_REGIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<input
aria-label={t("gitIdentity.alias")}
title={t("gitIdentity.aliasHint")}
placeholder={named ?? t("gitIdentity.aliasPlaceholder")}
autoComplete="off"
value={v.alias}
onChange={(e) => setV({ ...v, alias: e.target.value })}
className={`${INPUT} font-mono`}
/>
<button type="submit" className={BTN} disabled={busy || !v.keyId.trim()}>
{t("gitIdentity.awsSave")}
</button>
</form>
);
}
/**
* Step 3 of a named GitHub/GitLab key: the host it is for. Saved as the login
* `ssh://<key name>/` → `git@<host>`, tied to this key, so a clone through it
* offers only this key — the default key would log in as the other account.
*/
function GitHostForm({
busy,
onSave,
}: {
busy: boolean;
onSave: (host: string) => Promise<boolean>;
}) {
const { t } = useTranslation("settings");
const [host, setHost] = useState("github.com");
return (
<form
className="mt-1 flex gap-2"
onSubmit={(e) => {
e.preventDefault();
void onSave(host.trim());
}}
>
<input
aria-label={t("gitIdentity.otherHost")}
placeholder="github.com"
autoComplete="off"
value={host}
onChange={(e) => setHost(e.target.value)}
className={`${INPUT} font-mono`}
/>
<button type="submit" className={BTN} disabled={busy || !host.trim()}>
{t("gitIdentity.awsSave")}
</button>
</form>
);
}
export function GitIdentitySettings() {
const { t } = useTranslation("settings");
const [id, setId] = useState<GitIdentity | null>(null);
@@ -229,19 +318,42 @@ export function GitIdentitySettings() {
const keyOf = (type: "ed25519" | "rsa") =>
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
const named = keys.filter((k) => k.name);
const namedOf = (type: "ed25519" | "rsa") => named.filter((k) => (k.type ?? "ed25519") === type);
const keyLabel = (keyId: string | null | undefined) => {
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
if (!k) return t("gitIdentity.anyKey");
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
};
const [newKey, setNewKey] = useState<{ name: string; type: "ed25519" | "rsa" } | null>(null);
const makeNamed = async () => {
if (!newKey) return;
const ok = await run(
() => api.gitIdentity.generateKey(false, newKey.type, newKey.name.trim().toLowerCase()),
// "Add another key" in a tab makes the next key of that tab's type at once,
// named `aws-2`, `aws-3`… / `git-2`… — the key is what step 1 needs, and an
// account name of the user's own goes in its step 3.
const makeNamed = (type: "ed25519" | "rsa") => {
const prefix = type === "rsa" ? "aws" : "git";
let n = 2;
while (keys.some((k) => k.name === `${prefix}-${n}`)) n++;
return run(
() => api.gitIdentity.generateKey(false, type, `${prefix}-${n}`),
t("gitIdentity.keyMade")
);
};
const addKey = (type: "ed25519" | "rsa") => (
<button type="button" className={BTN} disabled={busy} onClick={() => void makeNamed(type)}>
<Plus className="h-3.5 w-3.5" />
{t("gitIdentity.addKey")}
</button>
);
const replaceNamed = async (k: Key) => {
if (
!(await confirmDialog({
title: t("gitIdentity.replaceConfirm"),
confirmLabel: t("gitIdentity.replace"),
}))
)
return;
await run(
() => api.gitIdentity.generateKey(true, k.type ?? "ed25519", k.name ?? undefined),
t("gitIdentity.keyMade")
);
if (ok) setNewKey(null);
};
const removeNamed = async (keyId: string) => {
if (
@@ -304,29 +416,60 @@ export function GitIdentitySettings() {
return m ? [{ ...r, region: m[1] ?? "" }] : [];
});
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1", alias: "", key: "" });
const saveAws = async () => {
let user = aws.keyId.trim();
let host = `git-codecommit.${aws.region}.amazonaws.com`;
// A login is shown under the key it names; one naming no key (or one gone)
// under the default key.
const namedIds = new Set(named.map((k) => k.id));
const rowsOf = <R extends { key?: string | null }>(rows: R[], k?: Key) =>
k ? rows.filter((r) => r.key === k.id) : rows.filter((r) => !r.key || !namedIds.has(r.key));
const saveAws = (v: { keyId: string; region: string; alias: string }, k?: Key) => {
let user = v.keyId.trim();
let host = `git-codecommit.${v.region}.amazonaws.com`;
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
if (m?.[1] && m[2]) {
user = m[1];
host = m[2].toLowerCase();
}
const alias = aws.alias.trim().toLowerCase();
const ok = await run(
// A named key's account goes by the key's name unless another is typed.
const alias = v.alias.trim().toLowerCase() || (k?.name ?? "");
return run(
() =>
api.gitIdentity.setSshUser({
host,
user,
...(alias ? { alias } : {}),
...(aws.key ? { key: aws.key } : {}),
...(k?.id ? { key: k.id } : {}),
}),
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
);
if (ok) setAws({ ...aws, keyId: "", alias: "" });
};
const saveGitHost = (host: string, k: Key) => {
const h = /^(?:ssh:\/\/)?(?:[^@/]+@)?([^/:]+)/i.exec(host)?.[1]?.toLowerCase() ?? host;
return run(
() => api.gitIdentity.setSshUser({ host: h, user: "git", alias: k.name!, key: k.id! }),
t("gitIdentity.otherSaved", { host: k.name })
);
};
/** The logins of one key, each with its delete button. */
const loginRows = <R extends SshUser>(rows: R[], text: (r: R) => string) =>
rows.map((r) => (
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{text(r)}
</span>
{id?.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
));
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
const saveOther = async () => {
let host = other.host.trim();
@@ -346,7 +489,7 @@ export function GitIdentitySettings() {
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
};
/** The key picker of a login form: every key, or one. */
const keySelect = (value: string, onChange: (v: string) => void, only?: "rsa") => (
const keySelect = (value: string, onChange: (v: string) => void) => (
<select
aria-label={t("gitIdentity.loginKey")}
value={value}
@@ -355,7 +498,7 @@ export function GitIdentitySettings() {
>
<option value="">{t("gitIdentity.anyKey")}</option>
{keys
.filter((k) => k.id && (!only || k.type === only))
.filter((k) => k.id)
.map((k) => (
<option key={k.id} value={k.id}>
{keyLabel(k.id)}
@@ -396,17 +539,6 @@ export function GitIdentitySettings() {
const sshTabs = [
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud },
...(named.length > 0 || id.writable
? [
{
id: "more",
label: named.length
? `${t("gitIdentity.tabMore")} (${named.length})`
: t("gitIdentity.tabMore"),
Icon: KeySquare,
},
]
: []),
...(id.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
: []),
@@ -498,228 +630,116 @@ export function GitIdentitySettings() {
onSelect={setSshTab}
>
{activeSshTab === "git" && (
<KeyCard
title={t("gitIdentity.gitHubTitle")}
sub={t("gitIdentity.gitHubSub")}
type="ed25519"
k={keyOf("ed25519")}
id={id}
busy={busy}
onGenerate={generate}
onRemove={removeKey}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
<Step n={3}>
{t("gitIdentity.gitHubStepClone")}
<Code>git clone git@github.com:&lt;org&gt;/&lt;repo&gt;.git</Code>
</Step>
</KeyCard>
<div className="space-y-2">
<KeyCard
title={t("gitIdentity.gitHubTitle")}
sub={t("gitIdentity.gitHubSub")}
label="ED25519"
k={keyOf("ed25519")}
writable={id.writable}
busy={busy}
onGenerate={() => generate("ed25519")}
onRemove={() => removeKey("ed25519")}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
<Step n={3}>
{t("gitIdentity.gitHubStepClone")}
<Code>git clone git@github.com:&lt;org&gt;/&lt;repo&gt;.git</Code>
</Step>
</KeyCard>
{namedOf("ed25519").map((k) => {
const rows = rowsOf(otherRows, k);
return (
<KeyCard
key={k.id}
title={k.name!}
sub={t("gitIdentity.namedGitSub")}
label={k.name!}
k={k}
writable={id.writable}
busy={busy}
onGenerate={() => replaceNamed(k)}
onRemove={() => removeNamed(k.id!)}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
<Step n={3}>
{t("gitIdentity.namedGitStepHost", { name: k.name })}
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
{id.writable && rows.length === 0 && (
<GitHostForm busy={busy} onSave={(host) => saveGitHost(host, k)} />
)}
{rows.map((r) => (
<Code key={r.alias ?? r.host}>
git clone ssh://{r.alias ?? r.host}/&lt;org&gt;/&lt;repo&gt;.git
</Code>
))}
</Step>
</KeyCard>
);
})}
{id.writable &&
keys.some((k) => (k.type ?? "ed25519") === "ed25519") &&
addKey("ed25519")}
</div>
)}
{activeSshTab === "aws" && (
<KeyCard
title={t("gitIdentity.awsTitle")}
sub={t("gitIdentity.awsSub")}
type="rsa"
k={keyOf("rsa")}
id={id}
busy={busy}
onGenerate={generate}
onRemove={removeKey}
>
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
<Step n={3}>
{t("gitIdentity.awsStepKeyId")}
{id.sshUsers === undefined ? null : (
<>
{awsRows.map((r) => (
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{r.alias ? `${r.alias} · ` : ""}
{r.region} · {r.user} · {keyLabel(r.key)}
</span>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
onClick={() =>
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
<div className="space-y-2">
{[keyOf("rsa"), ...namedOf("rsa")].map((k, i) => {
const isNamed = i > 0 && !!k;
const rows = rowsOf(awsRows, isNamed ? k : undefined);
return (
<KeyCard
key={k?.id ?? "id_rsa"}
title={isNamed ? k!.name! : t("gitIdentity.awsTitle")}
sub={isNamed ? t("gitIdentity.namedAwsSub") : t("gitIdentity.awsSub")}
label={isNamed ? k!.name! : "RSA"}
k={k}
writable={id.writable}
busy={busy}
onGenerate={() => (isNamed ? replaceNamed(k!) : generate("rsa"))}
onRemove={() => (isNamed ? removeNamed(k!.id!) : removeKey("rsa"))}
>
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
<Step n={3}>
{t("gitIdentity.awsStepKeyId")}
{id.sshUsers === undefined ? null : (
<>
{loginRows(
rows,
(r) =>
`${r.alias ? `${r.alias} · ` : ""}${r.region} · ${r.user} · ${keyLabel(r.key)}`
)}
{id.writable && (
<AwsLoginForm
busy={busy}
named={isNamed ? k!.name! : undefined}
onSave={(v) => saveAws(v, isNamed ? k : undefined)}
/>
)}
</>
)}
</Step>
{rows.length > 0 && (
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
{t("gitIdentity.awsReady")}
{rows.map((r) => (
<Code key={r.alias ?? r.host}>
git clone ssh://{r.alias ?? r.host}/v1/repos/&lt;repo&gt;
</Code>
))}
</div>
))}
{id.writable && (
<form
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault();
void saveAws();
}}
>
<input
aria-label={t("gitIdentity.awsKeyId")}
placeholder="APKA…"
autoComplete="off"
value={aws.keyId}
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.awsRegion")}
value={aws.region}
onChange={(e) => setAws({ ...aws, region: e.target.value })}
className="select !py-1 !text-xs"
>
{AWS_REGIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<input
aria-label={t("gitIdentity.alias")}
title={t("gitIdentity.aliasHint")}
placeholder={t("gitIdentity.aliasPlaceholder")}
autoComplete="off"
value={aws.alias}
onChange={(e) => setAws({ ...aws, alias: e.target.value })}
className={`${INPUT} font-mono`}
/>
{keySelect(aws.key, (key) => setAws({ ...aws, key }), "rsa")}
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
{t("gitIdentity.awsSave")}
</button>
</form>
)}
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
</>
)}
</Step>
{awsRows.length > 0 && (
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
{t("gitIdentity.awsReady")}
{awsRows.map((r) => (
<Code key={r.alias ?? r.host}>
git clone ssh://{r.alias ?? r.host}/v1/repos/&lt;repo&gt;
</Code>
))}
</div>
</KeyCard>
);
})}
{id.writable && keys.some((k) => k.type === "rsa") && (
<>
{addKey("rsa")}
<div className="text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
</>
)}
</KeyCard>
)}
{activeSshTab === "more" && (
<section
aria-label={t("gitIdentity.moreKeysTitle")}
className="space-y-2 rounded border border-border p-2.5"
>
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-fg-primary">
{t("gitIdentity.moreKeysTitle")}
</div>
<div className="text-[10px] text-fg-muted">{t("gitIdentity.moreKeysSub")}</div>
</div>
{id.writable && !newKey && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => setNewKey({ name: "", type: "rsa" })}
>
<Plus className="h-3.5 w-3.5" />
{t("gitIdentity.newKey")}
</button>
)}
</div>
{newKey && (
<form
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
onSubmit={(e) => {
e.preventDefault();
void makeNamed();
}}
>
<input
aria-label={t("gitIdentity.keyName")}
placeholder={t("gitIdentity.keyNamePlaceholder")}
autoComplete="off"
autoFocus
value={newKey.name}
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.keyType")}
value={newKey.type}
onChange={(e) =>
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
}
className="select !py-1 !text-xs"
>
<option value="rsa">RSA 4096 (AWS)</option>
<option value="ed25519">ED25519</option>
</select>
<button type="submit" className={BTN} disabled={busy || !newKey.name.trim()}>
{t("gitIdentity.generate")}
</button>
<button type="button" className={BTN} onClick={() => setNewKey(null)}>
{t("gitCreds.cancel")}
</button>
</form>
)}
{named.map((k) => (
<div
key={k.id}
className="space-y-1 rounded border border-border-light p-2"
data-testid="git-named-key"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-[11px] font-medium text-fg-primary">
{k.name}
</span>
<span className="rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
{k.type === "rsa" ? "RSA" : "ED25519"}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
{k.fingerprint}
</span>
<button
type="button"
className={BTN}
onClick={() => void copyText(k.publicKey)}
>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
onClick={() => void removeNamed(k.id!)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<textarea
readOnly
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
value={k.publicKey}
rows={2}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} resize-none break-all font-mono text-[10px]`}
/>
</div>
))}
</section>
</div>
)}
{activeSshTab === "other" && (
@@ -0,0 +1,33 @@
/**
* @file ChangesPanelSettings.test.tsx
* @description Settings → Git → Changes panel: the inner-repositories switch
* starts off, turning it on stores `all` in the synced account preference,
* and turning it off again removes the key (back to the default).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ChangesPanelSettings } from "../ChangesPanelSettings";
import { INNER_REPOS_KEY, readInnerReposMode } from "../../../lib/innerReposPref";
describe("ChangesPanelSettings", () => {
beforeEach(() => localStorage.clear());
it("is off by default and toggles the account preference", () => {
render(<ChangesPanelSettings />);
const sw = screen.getByRole("switch", {
name: /Show the repositories inside the workspace folder/,
});
expect(sw).toHaveAttribute("aria-checked", "false");
expect(readInnerReposMode()).toBe("own");
fireEvent.click(sw);
expect(localStorage.getItem(INNER_REPOS_KEY)).toBe("all");
expect(sw).toHaveAttribute("aria-checked", "true");
fireEvent.click(sw);
expect(localStorage.getItem(INNER_REPOS_KEY)).toBeNull();
expect(readInnerReposMode()).toBe("own");
});
});
@@ -5,9 +5,9 @@
* view leaves HTTPS to the shared Git credentials table, and an SSH user per
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any
* other host under "Other SSH hosts" — each kind of host in its own tab, so
* the sections never mix; a named key
* is made beside the default ones, and a second AWS account is saved under
* its own name with the one key it uses.
* the sections never mix; "Add another key" makes a named key as one more
* card in its own tab, a second AWS account is saved from that card under
* the key's name with that key, and a second GitHub key is tied to its host.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -216,9 +216,9 @@ describe("GitIdentitySettings", () => {
fingerprint: "SHA256:r",
};
const PROD = {
id: "id_rsa_aws-prod",
id: "id_rsa_aws-2",
type: "rsa" as const,
name: "aws-prod",
name: "aws-2",
publicKey: "ssh-rsa B prod",
fingerprint: "SHA256:p",
};
@@ -230,45 +230,78 @@ describe("GitIdentitySettings", () => {
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
view({
sshKeys: [RSA, PROD],
sshUsers: [{ host: H, user: "APKA2", alias: "aws-prod", key: "id_rsa_aws-prod" }],
sshUsers: [{ host: H, user: "APKA2", alias: "aws-2", key: "id_rsa_aws-2" }],
})
);
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
view({ sshKeys: [RSA], sshUsers: [] })
);
render(<GitIdentitySettings />);
// Only an RSA key: the AWS tab opens first.
// Only an RSA key: the AWS tab opens first; the new key is made inside it.
expect(await screen.findByRole("region", { name: "AWS CodeCommit" })).toBeInTheDocument();
await tab("More keys");
let more = screen.getByRole("region", { name: "More SSH keys" });
await userEvent.click(within(more).getByRole("button", { name: "New key" }));
await userEvent.type(within(more).getByLabelText("Key name"), "AWS-Prod");
await userEvent.click(within(more).getByRole("button", { name: "Generate key" }));
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-prod");
expect(await within(more).findByLabelText("Public key aws-prod")).toHaveValue("ssh-rsa B prod");
expect(screen.queryByRole("tab", { name: /More keys/ })).toBeNull();
// One click makes the key — nothing to type before it.
await userEvent.click(screen.getByRole("button", { name: "Add another key" }));
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-2");
const prod = await screen.findByRole("region", { name: "aws-2" });
expect(within(prod).getByLabelText("Public key aws-2")).toHaveValue("ssh-rsa B prod");
// Another key can be added below it.
expect(screen.getByRole("button", { name: "Add another key" })).toBeInTheDocument();
await tab("AWS CodeCommit");
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
await userEvent.type(within(aws).getByLabelText("SSH key ID"), "APKA2");
await userEvent.type(within(aws).getByLabelText("Account name"), "aws-prod");
await userEvent.selectOptions(within(aws).getByLabelText("Key"), "id_rsa_aws-prod");
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
// Its own step 3: the account name defaults to the key's name.
await userEvent.type(within(prod).getByLabelText("SSH key ID"), "APKA2");
await userEvent.click(within(prod).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
host: H,
user: "APKA2",
alias: "aws-prod",
key: "id_rsa_aws-prod",
alias: "aws-2",
key: "id_rsa_aws-2",
});
expect(
await within(aws).findByText("aws-prod · ap-southeast-1 · APKA2 · aws-prod")
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
).toBeInTheDocument();
expect(within(aws).getByText("git clone ssh://aws-prod/v1/repos/<repo>")).toBeInTheDocument();
expect(within(prod).getByText("git clone ssh://aws-2/v1/repos/<repo>")).toBeInTheDocument();
// The default key's card does not list the named key's login.
const def = screen.getByRole("region", { name: "AWS CodeCommit" });
expect(within(def).queryByText(/APKA2/)).toBeNull();
await tab("More keys (1)");
more = screen.getByRole("region", { name: "More SSH keys" });
await userEvent.click(within(more).getByRole("button", { name: "Delete aws-prod" }));
await waitFor(() =>
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-prod")
await userEvent.click(within(prod).getByRole("button", { name: "Delete key aws-2" }));
await waitFor(() => expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-2"));
});
it("adds a second GitHub key in its tab and ties it to its host", async () => {
const ED = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
const WORK = {
id: "id_ed25519_git-2",
type: "ed25519" as const,
name: "git-2",
publicKey: "ssh-ed25519 W work",
fingerprint: "SHA256:w",
};
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [ED], sshUsers: [] }));
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
view({ sshKeys: [ED, WORK], sshUsers: [] })
);
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
view({
sshKeys: [ED, WORK],
sshUsers: [{ host: "github.com", user: "git", alias: "git-2", key: "id_ed25519_git-2" }],
})
);
render(<GitIdentitySettings />);
await userEvent.click(await screen.findByRole("button", { name: "Add another key" }));
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519", "git-2");
const work = await screen.findByRole("region", { name: "git-2" });
expect(within(work).getByLabelText("Public key git-2")).toHaveValue("ssh-ed25519 W work");
await userEvent.click(within(work).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
host: "github.com",
user: "git",
alias: "git-2",
key: "id_ed25519_git-2",
});
expect(
await within(work).findByText("git clone ssh://git-2/<org>/<repo>.git")
).toBeInTheDocument();
});
});
+15 -1
View File
@@ -183,5 +183,19 @@
"stat.updated": "Updated",
"detail.runAs": "Run it as",
"detail.market": "Marketplace",
"detail.added": "Added {{date}}"
"detail.added": "Added {{date}}",
"tab.unowned": "Unowned",
"tab.suggested": "Is this yours?",
"auto": "Auto",
"autoHint": "Added when its session stopped: a document the session created or changed",
"unowned.hint": "Items no workspace owns that were made in your folders — confirm whether they are yours",
"unowned.hintAdmin": "Items with no owner: sessions outside any workspace, run as the host account",
"unowned.mine": "It's mine",
"unowned.takeIt": "Take it",
"unowned.notMine": "Not mine",
"unowned.assign": "Assign to…",
"unowned.claimed": "Added to your library",
"unowned.declined": "Removed from your list",
"unowned.assigned": "Owner assigned",
"unowned.empty": "Nothing without an owner."
}
+20 -1
View File
@@ -213,6 +213,7 @@
"clone.parentHint": "The repository is cloned to {{path}}",
"clone.nameLabel": "Folder name",
"clone.cloning": "Cloning…",
"clone.background": "Run in background",
"clone.loginNeeded": "{{host}} needs a login: your username and a password or access token.",
"clone.loginRejected": "{{host}} rejected that username or token.",
"clone.username": "Username",
@@ -656,5 +657,23 @@
"fx.readOnly": "Read-only",
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.",
"tmuxUpgrade": "Upgrade tmux: {{advice}}"
"tmuxUpgrade": "Upgrade tmux: {{advice}}",
"changes.rescanRepos": "Scan for repositories again",
"tree.newGroup": "New group",
"tree.groupDefaultName": "New group",
"tree.ungrouped": "Ungrouped",
"tree.groupToggle": "Show or hide the {{name}} group",
"tree.groupMenu": "Group options",
"tree.groupRename": "Rename group",
"tree.groupDelete": "Delete group",
"tree.groupMoveUp": "Move group up",
"tree.groupMoveDown": "Move group down",
"tree.groupDeleteConfirm": "Delete the group \"{{name}}\"? Its workspaces stay, ungrouped.",
"tree.groupNameLabel": "Group name",
"menu.moveToGroup": "Move to group",
"menu.removeFromGroup": "Remove from group",
"menu.newGroupWith": "New group…",
"tree.groupAddWorkspace": "New workspace in this group…",
"tree.groupFoldAll": "Fold all groups",
"tree.groupUnfoldAll": "Unfold all groups"
}
+17 -9
View File
@@ -908,16 +908,14 @@
"alias": "Account name",
"aliasPlaceholder": "name (optional)",
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
"awsMany": "Several AWS accounts? Make one more RSA key in the More keys tab for each, upload it to that account, then save its SSH key ID here with an account name and that key. Clone each with ssh://<account name>/v1/repos/<repo>.",
"moreKeysTitle": "More SSH keys",
"moreKeysSub": "One per extra account — another AWS account, a second GitHub user. A login that names its key uses only that key.",
"newKey": "New key",
"keyName": "Key name",
"keyNamePlaceholder": "e.g. aws-prod",
"keyType": "Key type",
"awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3 (the account name is the key’s unless you type one). Clone each with ssh://<account name>/v1/repos/<repo>.",
"tabGit": "GitHub · GitLab",
"tabMore": "More keys",
"tabOther": "Other hosts"
"tabOther": "Other hosts",
"addKey": "Add another key",
"namedGitSub": "ED25519 · for one more GitHub/GitLab account.",
"namedGitStepHost": "Tie it to its host, so a clone through ssh://{{name}}/… offers only this key (git@host would log in with the default key’s account):",
"namedAwsSub": "RSA · for one more AWS account.",
"removeKeyNamed": "Delete key {{name}}"
},
"gitCreds": {
"title": "Git credentials",
@@ -1077,6 +1075,12 @@
"noMatch": "No setting matches “{{query}}”.",
"results": "Search results"
},
"changesPanel": {
"title": "Changes panel",
"description": "For a workspace folder that holds several git repositories (each with its own .git). Saved to your account.",
"innerRepos": "Show the repositories inside the workspace folder",
"innerReposDesc": "Each repository found inside the folder (up to two levels down) gets its own section in the Changes panel. Off: only the folder's own git is shown."
},
"worktrees": {
"title": "Worktree suggestions",
"description": "When you add a folder to a workspace, the repo's worktrees that have no workspace are suggested. Saved in this browser only.",
@@ -1136,6 +1140,10 @@
"18": "Larger"
}
},
"nativePaste": {
"label": "Paste images natively",
"description": "On this machine, an image pasted into a Claude pane is read by Claude from your clipboard, like in a normal terminal — nothing is uploaded. Turn it off if you open the dashboard through an SSH tunnel: Claude would read the server's clipboard. This browser only."
},
"terminalFont": {
"title": "Terminal font",
"description": "The font and size of the workspace terminals. Open terminals change at once. Saved to your account.",
+6 -1
View File
@@ -36,5 +36,10 @@
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
"autoFixWorkspace": "AgentHub update",
"autoFixError": "Could not start the Auto fix session",
"backgroundNote": "The update keeps running in the background — the page reloads itself when the new version is up."
"backgroundNote": "The update keeps running in the background — the page reloads itself when the new version is up.",
"chipTitle": "Updating to {{version}}…",
"chipHint": "The update runs in the background — click to show it",
"pluginStep.marketplace_update": "Step {{index}}/{{total}}: refreshing the marketplace",
"pluginStep.plugin_update": "Step {{index}}/{{total}}: downloading the new version",
"pluginStep.bootstrap": "Step {{index}}/{{total}}: installing it and restarting the dashboard"
}
+1
View File
@@ -5,6 +5,7 @@
"role": "Role",
"workspaces": "Workspaces",
"sessions": "Sessions",
"today": "Today",
"last7d": "7 days",
"last30d": "30 days",
"storage": "Storage",
+15 -1
View File
@@ -183,5 +183,19 @@
"stat.updated": "Cập nhật",
"detail.runAs": "Gọi bằng",
"detail.market": "Marketplace",
"detail.added": "Thêm lúc {{date}}"
"detail.added": "Thêm lúc {{date}}",
"tab.unowned": "Vô chủ",
"tab.suggested": "Có phải của bạn?",
"auto": "Tự động",
"autoHint": "Tự thêm khi phiên làm việc dừng: file tài liệu phiên đã tạo hoặc sửa",
"unowned.hint": "Những mục không thuộc workspace nào nhưng được tạo trong thư mục của bạn — xác nhận có phải của bạn không",
"unowned.hintAdmin": "Những mục chưa có chủ: phiên không thuộc workspace nào, chạy bằng tài khoản host",
"unowned.mine": "Của tôi",
"unowned.takeIt": "Nhận về tôi",
"unowned.notMine": "Không phải của tôi",
"unowned.assign": "Gán cho…",
"unowned.claimed": "Đã nhận về thư viện của bạn",
"unowned.declined": "Đã bỏ khỏi danh sách của bạn",
"unowned.assigned": "Đã gán chủ",
"unowned.empty": "Không có mục nào chưa có chủ."
}
+20 -1
View File
@@ -213,6 +213,7 @@
"clone.parentHint": "Repository sẽ được clone vào {{path}}",
"clone.nameLabel": "Tên thư mục",
"clone.cloning": "Đang clone…",
"clone.background": "Chạy nền",
"clone.loginNeeded": "{{host}} cần đăng nhập: username và mật khẩu hoặc access token.",
"clone.loginRejected": "{{host}} từ chối username hoặc token này.",
"clone.username": "Username",
@@ -656,5 +657,23 @@
"fx.readOnly": "Chỉ đọc",
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái.",
"tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}"
"tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}",
"changes.rescanRepos": "Quét lại các repository",
"tree.newGroup": "Nhóm mới",
"tree.groupDefaultName": "Nhóm mới",
"tree.ungrouped": "Chưa phân nhóm",
"tree.groupToggle": "Hiện hoặc ẩn nhóm {{name}}",
"tree.groupMenu": "Tuỳ chọn nhóm",
"tree.groupRename": "Đổi tên nhóm",
"tree.groupDelete": "Xoá nhóm",
"tree.groupMoveUp": "Đưa nhóm lên",
"tree.groupMoveDown": "Đưa nhóm xuống",
"tree.groupDeleteConfirm": "Xoá nhóm \"{{name}}\"? Các workspace trong nhóm vẫn giữ nguyên, chỉ không còn thuộc nhóm.",
"tree.groupNameLabel": "Tên nhóm",
"menu.moveToGroup": "Chuyển vào nhóm",
"menu.removeFromGroup": "Bỏ khỏi nhóm",
"menu.newGroupWith": "Nhóm mới…",
"tree.groupAddWorkspace": "Workspace mới trong nhóm này…",
"tree.groupFoldAll": "Thu gọn mọi nhóm",
"tree.groupUnfoldAll": "Mở rộng mọi nhóm"
}
+17 -9
View File
@@ -896,16 +896,14 @@
"alias": "Tên tài khoản",
"aliasPlaceholder": "tên (tuỳ chọn)",
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
"awsMany": "Nhiều tài khoản AWS? Tạo thêm một RSA key ở tab Key khác cho mỗi tài khoản, upload lên tài khoản đó, rồi lưu SSH key ID ở đây kèm tên tài khoản và key đó. Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
"moreKeysTitle": "Thêm SSH key",
"moreKeysSub": "Mỗi key cho một tài khoản phụ — tài khoản AWS khác, user GitHub thứ hai. Tài khoản nào gắn key thì chỉ dùng đúng key đó.",
"newKey": "Key mới",
"keyName": "Tên key",
"keyNamePlaceholder": "vd aws-prod",
"keyType": "Loại key",
"awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó (tên tài khoản lấy theo tên key nếu bạn không nhập). Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
"tabGit": "GitHub · GitLab",
"tabMore": "Key khác",
"tabOther": "Host khác"
"tabOther": "Host khác",
"addKey": "Thêm key khác",
"namedGitSub": "ED25519 · cho thêm một tài khoản GitHub/GitLab.",
"namedGitStepHost": "Gắn key với host của nó, để clone qua ssh://{{name}}/… chỉ dùng đúng key này (git@host sẽ đăng nhập bằng tài khoản của key mặc định):",
"namedAwsSub": "RSA · cho thêm một tài khoản AWS.",
"removeKeyNamed": "Xoá key {{name}}"
},
"gitCreds": {
"title": "Git credentials",
@@ -1065,6 +1063,12 @@
"noMatch": "Không có cài đặt nào khớp “{{query}}”.",
"results": "Kết quả tìm kiếm"
},
"changesPanel": {
"title": "Panel Changes",
"description": "Dành cho thư mục workspace chứa nhiều repo git (mỗi repo có .git riêng). Lưu theo tài khoản của bạn.",
"innerRepos": "Hiện các repo bên trong thư mục workspace",
"innerReposDesc": "Mỗi repo tìm thấy trong thư mục (sâu tối đa hai cấp) có một mục riêng trong panel Changes. Tắt: chỉ hiện git của chính thư mục."
},
"worktrees": {
"title": "Gợi ý worktree",
"description": "Khi thêm thư mục cho một workspace, các worktree của repo chưa có workspace sẽ được gợi ý. Chỉ lưu trong trình duyệt này.",
@@ -1124,6 +1128,10 @@
"18": "Rất lớn"
}
},
"nativePaste": {
"label": "Dán ảnh kiểu native",
"description": "Trên máy này, ảnh dán vào pane Claude được Claude tự đọc từ clipboard, như trong terminal thường — không upload gì. Tắt nếu bạn mở dashboard qua SSH tunnel: khi đó Claude sẽ đọc clipboard của server. Chỉ áp dụng cho trình duyệt này."
},
"terminalFont": {
"title": "Font terminal",
"description": "Font và cỡ chữ của các terminal trong workspace. Terminal đang mở đổi ngay. Lưu vào tài khoản của bạn.",
+6 -1
View File
@@ -36,5 +36,10 @@
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
"autoFixWorkspace": "Cập nhật AgentHub",
"autoFixError": "Không mở được phiên Tự sửa",
"backgroundNote": "Bản cập nhật vẫn chạy ngầm — trang sẽ tự tải lại khi phiên bản mới sẵn sàng."
"backgroundNote": "Bản cập nhật vẫn chạy ngầm — trang sẽ tự tải lại khi phiên bản mới sẵn sàng.",
"chipTitle": "Đang cập nhật lên {{version}}…",
"chipHint": "Bản cập nhật đang chạy ngầm — bấm để xem",
"pluginStep.marketplace_update": "Bước {{index}}/{{total}}: làm mới marketplace",
"pluginStep.plugin_update": "Bước {{index}}/{{total}}: tải phiên bản mới",
"pluginStep.bootstrap": "Bước {{index}}/{{total}}: cài đặt và khởi động lại bảng điều khiển"
}
+1
View File
@@ -5,6 +5,7 @@
"role": "Vai trò",
"workspaces": "Workspace",
"sessions": "Phiên",
"today": "Hôm nay",
"last7d": "7 ngày",
"last30d": "30 ngày",
"storage": "Dung lượng",
@@ -0,0 +1,114 @@
/**
* @file workspaceGroups.test.ts
* @description Tests for the sidebar's workspace groups store: create, rename,
* move (by step or onto another group) and delete (which only ungroups), fold all, assignment, pruning of removed
* workspaces, a damaged stored value, and the arrangement the sidebar draws —
* groups in order, each keeping the drag order, ungrouped last.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import {
WORKSPACE_GROUPS_KEY,
arrangeByGroup,
assignGroup,
createGroup,
deleteGroup,
groupOf,
moveGroup,
moveGroupBefore,
pruneWorkspaceGroups,
readWorkspaceGroups,
renameGroup,
setAllFolded,
setGroupFolded,
} from "../workspaceGroups";
const lanes = (...ids: number[]) => ids.map((id) => ({ id }));
describe("workspaceGroups", () => {
beforeEach(() => localStorage.clear());
it("starts empty and survives a damaged value", () => {
expect(readWorkspaceGroups()).toEqual({ groups: [], of: {} });
localStorage.setItem(WORKSPACE_GROUPS_KEY, "{nope");
expect(readWorkspaceGroups()).toEqual({ groups: [], of: {} });
});
it("creates a group holding workspaces, renames, folds and deletes it", () => {
const id = createGroup(" Frontend ", [1, 2]);
let v = readWorkspaceGroups();
expect(v.groups).toEqual([{ id, name: "Frontend" }]);
expect(groupOf(v, 1)?.id).toBe(id);
renameGroup(id, " ");
expect(readWorkspaceGroups().groups[0]!.name).toBe("Frontend");
renameGroup(id, "Web");
setGroupFolded(id, true);
v = readWorkspaceGroups();
expect(v.groups[0]).toMatchObject({ name: "Web", folded: true });
deleteGroup(id);
v = readWorkspaceGroups();
expect(v.groups).toEqual([]);
expect(v.of).toEqual({});
});
it("moves a workspace between groups and out of them", () => {
const a = createGroup("A");
const b = createGroup("B");
assignGroup(5, a);
assignGroup(5, b);
expect(readWorkspaceGroups().of["5"]).toBe(b);
assignGroup(5, "missing");
expect(readWorkspaceGroups().of["5"]).toBeUndefined();
assignGroup(5, a);
assignGroup(5, null);
expect(readWorkspaceGroups().of["5"]).toBeUndefined();
});
it("reorders groups, clamped at both ends", () => {
const a = createGroup("A");
const b = createGroup("B");
moveGroup(a, 1);
expect(readWorkspaceGroups().groups.map((g) => g.id)).toEqual([b, a]);
moveGroup(a, 5);
expect(readWorkspaceGroups().groups.map((g) => g.id)).toEqual([b, a]);
});
it("forgets removed workspaces and ignores ids naming no group", () => {
const a = createGroup("A", [1, 2, 3]);
pruneWorkspaceGroups([1, 3]);
expect(readWorkspaceGroups().of).toEqual({ 1: a, 3: a });
localStorage.setItem(
WORKSPACE_GROUPS_KEY,
JSON.stringify({ groups: [{ id: a, name: "A" }], of: { 1: a, 2: "gone" } })
);
expect(readWorkspaceGroups().of).toEqual({ 1: a });
});
it("arranges groups in order, keeping the drag order, ungrouped last", () => {
const a = createGroup("A", [4, 2]);
const b = createGroup("B", [3]);
moveGroup(b, -1);
const v = readWorkspaceGroups();
expect(arrangeByGroup(lanes(1, 2, 3, 4, 5), v).map((l) => l.id)).toEqual([3, 2, 4, 1, 5]);
expect(groupOf(v, 2)?.id).toBe(a);
});
it("moves a group before another, or last, and folds every group at once", () => {
const a = createGroup("A");
const b = createGroup("B");
const c = createGroup("C");
const ids = () => readWorkspaceGroups().groups.map((g) => g.id);
moveGroupBefore(c, a);
expect(ids()).toEqual([c, a, b]);
moveGroupBefore(c, null);
expect(ids()).toEqual([a, b, c]);
moveGroupBefore(a, a);
moveGroupBefore("nope", b);
expect(ids()).toEqual([a, b, c]);
setAllFolded(true);
expect(readWorkspaceGroups().groups.every((g) => g.folded)).toBe(true);
setAllFolded(false);
expect(readWorkspaceGroups().groups.some((g) => g.folded)).toBe(false);
});
});
+48 -10
View File
@@ -538,6 +538,8 @@ export type ServerUserUsage = AuthUser & {
disabled: boolean;
workspaces: number;
sessions: number;
/** Since the viewer's local midnight (absent from an older server). */
today?: { cost: number; tokens: number };
last7d: { cost: number; tokens: number };
last30d: { cost: number; tokens: number };
/** Active login sessions (cookies not expired). */
@@ -893,11 +895,17 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
/**
* What a workspace git call works on: the workspace (its id) or, with
* `session`, that session's own worktree (`claude --worktree`), or, with
* `source`, one of the workspace's extra folders — the server resolves the
* tree from the session or `lane_sources` row (`?session=` / `?source=`),
* never from a path.
* `source`, one of the workspace's extra folders, or with `repo`, a repo
* inside the workspace folder — the server resolves the tree from the session
* or `lane_sources` row (`?session=` / `?source=`), or accepts `?repo=` only
* when its own walk of the folder found that repo; never from a path.
*/
export type GitTarget = number | { lane: number; session?: string; source?: number };
export type GitTarget = number | { lane: number; session?: string; source?: number; repo?: string };
/** Which extra repo a Changes section reads: an extra folder's `lane_sources`
* id (number), or a repo inside the workspace folder, by its path relative
* to it (string, from `GET /lanes/:id/repos`). */
export type GitSource = number | string;
/** `/lanes/<id>/git<sub>` plus `q` and the target's `session=` / `source=`. */
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
@@ -905,6 +913,7 @@ function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
const lane = typeof target === "number" ? target : target.lane;
if (typeof target !== "number" && target.session) params.set("session", target.session);
if (typeof target !== "number" && target.source) params.set("source", String(target.source));
if (typeof target !== "number" && target.repo) params.set("repo", target.repo);
const qs = params.toString();
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
}
@@ -1003,13 +1012,14 @@ export interface FsTrashItem {
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
export type FsConflict = "fail" | "skip" | "keep" | "replace";
/** The target for `laneId`: an extra folder's repo, else `session`'s worktree, else the workspace. */
/** The target for `laneId`: an extra folder's or an inner repo, else `session`'s worktree, else the workspace. */
export function gitTarget(
laneId: number,
session?: string | null,
source?: number | null
source?: GitSource | null
): GitTarget {
if (source) return { lane: laneId, source };
if (typeof source === "string" && source) return { lane: laneId, repo: source };
if (typeof source === "number" && source) return { lane: laneId, source };
return session ? { lane: laneId, session } : laneId;
}
@@ -1083,7 +1093,8 @@ export const api = {
avatarUrl: (id: number, avatarAt?: string | null) =>
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
/** GET /api/users — every account with its recent usage (admin/analyst). */
list: () => request<{ users: ServerUserUsage[] }>("/users"),
list: () =>
request<{ users: ServerUserUsage[] }>(`/users?tz_offset=${new Date().getTimezoneOffset()}`),
/** GET /api/users/audit — staff reads of members' sessions and admins'
* account changes, newest first. */
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
@@ -3016,7 +3027,9 @@ export const api = {
},
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
artifacts: {
list: (params: { scope?: "mine" | "hub" | "all"; kind?: ArtifactKind; q?: string } = {}) => {
list: (
params: { scope?: "mine" | "hub" | "all" | "unowned"; kind?: ArtifactKind; q?: string } = {}
) => {
const qs = new URLSearchParams();
if (params.scope) qs.set("scope", params.scope);
if (params.kind) qs.set("kind", params.kind);
@@ -3092,6 +3105,18 @@ export const api = {
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
return request<{ item: Artifact }>("/artifacts/file/upload", { method: "POST", body: form });
},
/** An unowned row: its suggested user (or an admin) takes it. */
claim: (id: number) =>
request<{ item: Artifact }>(`/artifacts/${id}/claim`, { method: "POST" }),
/** An unowned row offered to the caller is not theirs. */
decline: (id: number) =>
request<{ id: number; declined: true }>(`/artifacts/${id}/decline`, { method: "POST" }),
/** Admin: give an unowned row to a user. */
assign: (id: number, userId: number) =>
request<{ item: Artifact }>(`/artifacts/${id}/assign`, {
method: "POST",
body: JSON.stringify({ userId }),
}),
update: (
id: number,
body: {
@@ -4157,6 +4182,13 @@ export const api = {
/** GET /api/lanes/:id/sources — the workspace's instructions, extra
* folders and context files, and whether its tool can take them. */
sources: (id: number) => request<LaneSourcesView>(`/lanes/${id}/sources`),
/** GET /api/lanes/:id/repos — the git repos inside the workspace folder
* (a parent folder of several repos), each a Changes section read with
* `?repo=<path>`; `root` = the folder itself is in a repo. */
repos: (id: number, refresh = false) =>
request<{ root: boolean; items: { path: string; name: string }[]; truncated: boolean }>(
`/lanes/${id}/repos${refresh ? "?refresh=1" : ""}`
),
/** PUT /api/lanes/:id/instructions — appended to every new pane's system prompt. */
setInstructions: (id: number, instructions: string | null) =>
request<LaneSourcesView>(`/lanes/${id}/instructions`, {
@@ -4382,7 +4414,7 @@ export interface Artifact {
market: string | null;
/** page: the claude.ai link. */
url: string | null;
files: { path: string; size: number }[];
files: { path: string; size: number; mtime?: number }[];
size: number | null;
shared: boolean;
sharedAt: string | null;
@@ -4391,6 +4423,12 @@ export interface Artifact {
inLibrary: boolean;
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
installed?: boolean;
/** file: captured when its session stopped, not pinned by hand. */
auto?: boolean;
/** Server mode: nobody owns it yet. */
unowned?: boolean;
/** Unowned and offered to the caller to claim or decline. */
suggestedForMe?: boolean;
createdAt: string;
updatedAt: string;
}
+56
View File
@@ -0,0 +1,56 @@
/**
* @file innerReposPref — whether the Changes panel lists the git repositories
* INSIDE a workspace folder (a parent folder of several repos, one section
* each via `GET /api/lanes/:id/repos`) or only the folder's own git. Off by
* default ("own"): the panel neither walks the folder nor shows them until
* the account turns it on in Settings → Git → Changes panel. One account
* preference, `agenthub.changes.innerRepos` (`lib/userPrefs.ts`,
* `server/routes/me.js`), read through `useSyncExternalStore` and re-read
* when the account's preferences load (`PREFS_EVENT`) or another tab writes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useSyncExternalStore } from "react";
import { PREFS_EVENT } from "./workspaceGroups";
export const INNER_REPOS_KEY = "agenthub.changes.innerRepos";
export type InnerReposMode = "all" | "own";
const listeners = new Set<() => void>();
export function readInnerReposMode(): InnerReposMode {
try {
return localStorage.getItem(INNER_REPOS_KEY) === "all" ? "all" : "own";
} catch {
return "own";
}
}
export function writeInnerReposMode(mode: InnerReposMode): void {
try {
// The default is stored as no value, so an account that never chose keeps following it.
if (mode === "all") localStorage.setItem(INNER_REPOS_KEY, "all");
else localStorage.removeItem(INNER_REPOS_KEY);
} catch {
/* disabled storage: nothing to remember */
}
listeners.forEach((l) => l());
}
function subscribe(l: () => void) {
listeners.add(l);
const onStorage = (e: StorageEvent) => {
if (e.key === INNER_REPOS_KEY || e.key === null) l();
};
window.addEventListener("storage", onStorage);
window.addEventListener(PREFS_EVENT, l);
return () => {
listeners.delete(l);
window.removeEventListener("storage", onStorage);
window.removeEventListener(PREFS_EVENT, l);
};
}
export function useInnerReposMode(): InnerReposMode {
return useSyncExternalStore(subscribe, readInnerReposMode);
}
+32
View File
@@ -0,0 +1,32 @@
/**
* @file nativePaste — Settings → Appearance → "Paste images natively": a
* per-browser preference in `localStorage` (`agenthub.terminal.nativePaste`,
* deliberately NOT synced to the account — it is about this browser's
* machine). On (the default), a `claude` pane in a local-mode dashboard opened
* from this machine's own browser gets an image paste as `^V`, so `claude`
* reads the desktop clipboard itself like a native terminal — nothing is
* uploaded or copied. Off, the image is uploaded like a remote browser's. An
* SSH tunnel's browser also reaches the dashboard by loopback, and its `^V`
* would read the SERVER's clipboard — that is what turning it off is for.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export const NATIVE_PASTE_KEY = "agenthub.terminal.nativePaste";
/** True unless this browser turned native image paste off. */
export function readNativePaste(): boolean {
try {
return window.localStorage.getItem(NATIVE_PASTE_KEY) !== "off";
} catch {
return true;
}
}
export function writeNativePaste(on: boolean) {
try {
if (on) window.localStorage.removeItem(NATIVE_PASTE_KEY);
else window.localStorage.setItem(NATIVE_PASTE_KEY, "off");
} catch {
/* storage blocked — the default */
}
}
+6
View File
@@ -375,6 +375,12 @@ export const SETTINGS_INDEX: {
},
{ section: "git", titleKey: "worktrees.claude", to: "#worktrees" },
{ section: "git", titleKey: "worktrees.agenthub", to: "#worktrees" },
{
section: "git",
titleKey: "changesPanel.innerRepos",
keywords: ["repositories", "nested", "monorepo", "changes"],
to: "#changes-panel",
},
];
function tier(hay: string | undefined, q: string, exact: number): number {
+8
View File
@@ -1458,6 +1458,14 @@ export interface UpdateStatusPayload {
plugin_root?: string;
finished_at?: string;
} | null;
/** The step a running plugin-update chain is on (null between runs or on
* a server that predates it). Polled while an update runs. */
plugin_update_progress?: {
step: string;
index: number;
total: number;
started_at: string;
} | null;
}
// ───── Terminal run status ─────
+5
View File
@@ -20,6 +20,7 @@ import { api } from "./api";
import i18n from "../i18n";
import { applyStoredTheme } from "../hooks/useTheme";
import { FONT_SIZE_EVENT, applyUiFontSize } from "./fontSize";
import { PREFS_EVENT } from "./workspaceGroups";
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */
export const PREF_KEYS = [
@@ -44,6 +45,8 @@ export const PREF_KEYS = [
"agenthub.tour",
"agenthub.workspace.allTabs",
"agenthub.markdown.view",
"agenthub.workspace.groups",
"agenthub.changes.innerRepos",
] as const;
const SYNCED = new Set<string>(PREF_KEYS);
const PUSH_DELAY_MS = 400;
@@ -116,6 +119,8 @@ function reapply(prefs: Record<string, string>) {
window.dispatchEvent(new Event("sagi:prefs"));
applyUiFontSize();
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
// Every other store that reads these keys (the sidebar's workspace groups).
window.dispatchEvent(new Event(PREFS_EVENT));
const lng = prefs.i18nextLng;
if (lng && lng !== i18n.language) void i18n.changeLanguage(lng);
}
+221
View File
@@ -0,0 +1,221 @@
/**
* @file workspaceGroups — the sidebar's user-made workspace groups ("Frontend",
* "Client A", …): their names, order and fold state, and which group each
* workspace sits in. One JSON value under `agenthub.workspace.groups`, one of
* the account preference keys (`lib/userPrefs.ts`, `server/routes/me.js`), so
* the groups are kept per ACCOUNT on the server and follow it to any browser,
* machine or port, and through updates. Like the drag order, it is a viewer's
* arrangement, not a workspace fact: nothing about a lane changes, the CLI and
* `GET /api/lanes` know nothing of it, and deleting a group only ungroups its
* workspaces. A `useSyncExternalStore` reader cached on the raw string, so
* every reader agrees; it re-reads when the account's preferences are loaded
* (`PREFS_EVENT`) and when another tab writes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useSyncExternalStore } from "react";
export const WORKSPACE_GROUPS_KEY = "agenthub.workspace.groups";
/** Fired by `userPrefs.hydrate` once an account's values are in localStorage. */
export const PREFS_EVENT = "agenthub:prefs";
export interface WorkspaceGroup {
id: string;
name: string;
/** Folded shut in the sidebar. */
folded?: boolean;
}
export interface WorkspaceGroups {
/** In display order. */
groups: WorkspaceGroup[];
/** Lane id → group id. A lane absent here, or naming no group, is ungrouped. */
of: Record<string, string>;
}
const EMPTY: WorkspaceGroups = { groups: [], of: {} };
export const MAX_GROUP_NAME = 60;
const listeners = new Set<() => void>();
let cache: { raw: string | null; value: WorkspaceGroups } = { raw: null, value: EMPTY };
function readRaw(): string | null {
try {
return localStorage.getItem(WORKSPACE_GROUPS_KEY);
} catch {
return null;
}
}
function parse(raw: string | null): WorkspaceGroups {
if (!raw) return EMPTY;
try {
const v = JSON.parse(raw) as Partial<WorkspaceGroups>;
const groups = (Array.isArray(v.groups) ? v.groups : []).filter(
(g): g is WorkspaceGroup =>
!!g && typeof g.id === "string" && !!g.id && typeof g.name === "string"
);
const ids = new Set(groups.map((g) => g.id));
const of: Record<string, string> = {};
if (v.of && typeof v.of === "object") {
for (const [lane, gid] of Object.entries(v.of)) {
if (typeof gid === "string" && ids.has(gid)) of[lane] = gid;
}
}
return { groups, of };
} catch {
return EMPTY;
}
}
export function readWorkspaceGroups(): WorkspaceGroups {
const raw = readRaw();
if (raw === cache.raw) return cache.value;
cache = { raw, value: parse(raw) };
return cache.value;
}
function write(next: WorkspaceGroups): void {
try {
localStorage.setItem(WORKSPACE_GROUPS_KEY, JSON.stringify(next));
} catch {
/* disabled storage: the groups live until the page closes */
cache = { raw: JSON.stringify(next), value: next };
}
listeners.forEach((l) => l());
}
/** Change the groups through `fn` (given the current value, never mutated). */
export function updateWorkspaceGroups(fn: (cur: WorkspaceGroups) => WorkspaceGroups): void {
write(fn(readWorkspaceGroups()));
}
const cleanName = (name: string) => name.trim().slice(0, MAX_GROUP_NAME);
/** A new group at the end, holding `laneIds` (moved out of their old group). Returns its id. */
export function createGroup(name: string, laneIds: number[] = []): string {
const id = `g${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
updateWorkspaceGroups((cur) => {
const of = { ...cur.of };
for (const lane of laneIds) of[lane] = id;
return { groups: [...cur.groups, { id, name: cleanName(name) }], of };
});
return id;
}
/** Rename; a blank name keeps the old one. */
export function renameGroup(id: string, name: string): void {
const clean = cleanName(name);
if (!clean) return;
updateWorkspaceGroups((cur) => ({
...cur,
groups: cur.groups.map((g) => (g.id === id ? { ...g, name: clean } : g)),
}));
}
/** Delete the group; its workspaces become ungrouped (nothing else changes). */
export function deleteGroup(id: string): void {
updateWorkspaceGroups((cur) => ({
groups: cur.groups.filter((g) => g.id !== id),
of: Object.fromEntries(Object.entries(cur.of).filter(([, gid]) => gid !== id)),
}));
}
export function setGroupFolded(id: string, folded: boolean): void {
updateWorkspaceGroups((cur) => ({
...cur,
groups: cur.groups.map((g) => (g.id === id ? { ...g, folded } : g)),
}));
}
/** Move the group `by` places (−1 up, +1 down), clamped. */
export function moveGroup(id: string, by: number): void {
updateWorkspaceGroups((cur) => {
const from = cur.groups.findIndex((g) => g.id === id);
const to = Math.max(0, Math.min(cur.groups.length - 1, from + by));
if (from < 0 || from === to) return cur;
const groups = [...cur.groups];
groups.splice(to, 0, ...groups.splice(from, 1));
return { ...cur, groups };
});
}
/** Move the group to just before `beforeId` (a drag onto that header), or to the end (null). */
export function moveGroupBefore(id: string, beforeId: string | null): void {
updateWorkspaceGroups((cur) => {
const moving = cur.groups.find((g) => g.id === id);
if (!moving || id === beforeId) return cur;
const rest = cur.groups.filter((g) => g.id !== id);
const at = beforeId === null ? -1 : rest.findIndex((g) => g.id === beforeId);
const groups = at < 0 ? [...rest, moving] : [...rest.slice(0, at), moving, ...rest.slice(at)];
if (groups.every((g, i) => g === cur.groups[i])) return cur;
return { ...cur, groups };
});
}
/** Fold (or unfold) every group at once. */
export function setAllFolded(folded: boolean): void {
updateWorkspaceGroups((cur) => ({
...cur,
groups: cur.groups.map((g) => (!!g.folded === folded ? g : { ...g, folded })),
}));
}
/** Put a workspace in a group, or (null) take it out of every group. */
export function assignGroup(laneId: number, groupId: string | null): void {
updateWorkspaceGroups((cur) => {
const of = { ...cur.of };
if (groupId && cur.groups.some((g) => g.id === groupId)) of[laneId] = groupId;
else delete of[laneId];
return { ...cur, of };
});
}
/** Forget the lanes that no longer exist, so the stored value stays small. */
export function pruneWorkspaceGroups(laneIds: number[]): void {
const cur = readWorkspaceGroups();
const live = new Set(laneIds.map(String));
const stale = Object.keys(cur.of).filter((k) => !live.has(k));
if (!stale.length) return;
write({ ...cur, of: Object.fromEntries(Object.entries(cur.of).filter(([k]) => live.has(k))) });
}
/**
* `lanes` (already in the stored drag order) arranged by group: each group's
* workspaces in group order, keeping their relative order, then the
* ungrouped ones. Stable, never mutates.
*/
export function arrangeByGroup<T extends { id: number }>(lanes: T[], value: WorkspaceGroups): T[] {
const rank = new Map(value.groups.map((g, i) => [g.id, i]));
const at = (l: T) => {
const gid = value.of[l.id];
return gid !== undefined && rank.has(gid) ? rank.get(gid)! : value.groups.length;
};
return lanes
.map((l, i) => ({ l, i }))
.sort((a, b) => at(a.l) - at(b.l) || a.i - b.i)
.map((x) => x.l);
}
/** The group a workspace is in, or null. */
export function groupOf(value: WorkspaceGroups, laneId: number): WorkspaceGroup | null {
const gid = value.of[laneId];
return (gid && value.groups.find((g) => g.id === gid)) || null;
}
function subscribe(l: () => void) {
listeners.add(l);
const onStorage = (e: StorageEvent) => {
if (e.key === WORKSPACE_GROUPS_KEY || e.key === null) l();
};
window.addEventListener("storage", onStorage);
window.addEventListener(PREFS_EVENT, l);
return () => {
listeners.delete(l);
window.removeEventListener("storage", onStorage);
window.removeEventListener(PREFS_EVENT, l);
};
}
export function useWorkspaceGroups(): WorkspaceGroups {
return useSyncExternalStore(subscribe, readWorkspaceGroups);
}
+120 -13
View File
@@ -49,7 +49,9 @@ import {
type ArtifactInstalls,
type ArtifactKind,
type ArtifactSettings,
type ServerUserUsage,
} from "../lib/api";
import { usePermissions } from "../components/AuthGate";
import type { Lane } from "../lib/types";
import { eventBus } from "../lib/eventBus";
import { toast } from "../lib/toast";
@@ -114,7 +116,11 @@ const footAction =
export function Artifacts() {
const { t } = useTranslation("artifacts");
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
const [tab, setTab] = useState<"all" | "mine" | "hub">("all");
const [tab, setTab] = useState<"all" | "mine" | "hub" | "unowned">("all");
const { isAdmin } = usePermissions();
// Server mode: rows nobody owns — every one for an admin, those offered to you otherwise.
const [unownedCount, setUnownedCount] = useState(0);
const [users, setUsers] = useState<ServerUserUsage[]>([]);
const [kind, setKind] = useState<ArtifactKind | "">("");
const [q, setQ] = useState("");
const [items, setItems] = useState<Artifact[] | null>(null);
@@ -147,14 +153,45 @@ export function Artifacts() {
return () => window.clearTimeout(id);
}, [load, q]);
const serverMode = !!settings?.serverMode;
const countUnowned = useCallback(() => {
if (!serverMode) return;
api.artifacts
.list({ scope: "unowned" })
.then((r) => setUnownedCount(r.items.length))
.catch(() => setUnownedCount(0));
}, [serverMode]);
useEffect(countUnowned, [countUnowned]);
useEffect(() => {
if (tab !== "unowned" || !isAdmin || users.length) return;
api.users
.list()
.then((r) => setUsers(r.users.filter((u) => !u.disabled)))
.catch(() => setUsers([]));
}, [tab, isAdmin, users.length]);
useEffect(
() =>
eventBus.subscribe((msg) => {
if (msg.type === "artifact_changed") load();
if (msg.type === "artifact_changed") {
load();
countUnowned();
}
}),
[load]
[load, countUnowned]
);
const settle = async (run: () => Promise<unknown>, done: string) => {
try {
await run();
toast.success(t(done));
load();
countUnowned();
} catch (err) {
toast.error(err);
}
};
// The Add menu closes on Escape or a click anywhere outside it.
useEffect(() => {
if (!addMenu) return;
@@ -297,15 +334,63 @@ export function Artifacts() {
},
});
const card = (a: Artifact) => (
<ArtifactCard
key={a.id}
item={a}
server={server}
canInstall={!!settings?.installsEnabled}
{...actionsFor(a)}
/>
);
const card = (a: Artifact) =>
tab === "unowned" ? (
<div key={a.id} className="flex flex-col gap-2" data-testid={`unowned-${a.id}`}>
<ArtifactCard
item={a}
server={server}
canInstall={!!settings?.installsEnabled}
{...actionsFor(a)}
/>
<div className="flex flex-wrap items-center gap-2 text-xs">
{(a.suggestedForMe || isAdmin) && (
<button
type="button"
className="btn-primary px-2 py-1 text-xs"
onClick={() => settle(() => api.artifacts.claim(a.id), "unowned.claimed")}
>
{t(a.suggestedForMe ? "unowned.mine" : "unowned.takeIt")}
</button>
)}
{a.suggestedForMe && (
<button
type="button"
className={footAction}
onClick={() => settle(() => api.artifacts.decline(a.id), "unowned.declined")}
>
{t("unowned.notMine")}
</button>
)}
{isAdmin && users.length > 0 && (
<select
className="input h-7 w-auto py-0 text-xs"
aria-label={t("unowned.assign")}
value=""
onChange={(e) => {
const userId = Number(e.target.value);
if (userId) settle(() => api.artifacts.assign(a.id, userId), "unowned.assigned");
}}
>
<option value="">{t("unowned.assign")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName || u.username}
</option>
))}
</select>
)}
</div>
</div>
) : (
<ArtifactCard
key={a.id}
item={a}
server={server}
canInstall={!!settings?.installsEnabled}
{...actionsFor(a)}
/>
);
// Local mode (everything is yours): the unfiltered page is shelved by
// kind; a category or a search is one flat grid of results.
@@ -320,7 +405,7 @@ export function Artifacts() {
// what you shared, My library into what you installed from others (the
// server adds those to `mine`) and your own.
const halves =
server && items
server && items && tab !== "unowned"
? (["others", "own"] as const).map((who) => ({
key: `${tab}.${who}`,
rows: items.filter((a) => (who === "own" ? a.mine : !a.mine)),
@@ -424,12 +509,29 @@ export function Artifacts() {
{t(`tab.${v}`)}
</button>
))}
{(isAdmin || unownedCount > 0 || tab === "unowned") && (
<button
type="button"
aria-pressed={tab === "unowned"}
onClick={() => setTab("unowned")}
className={segment(tab === "unowned")}
data-testid="artifacts-tab-unowned"
title={t(isAdmin ? "unowned.hintAdmin" : "unowned.hint")}
>
{t(isAdmin ? "tab.unowned" : "tab.suggested")}
{unownedCount > 0 && <span className="ml-1 text-fg-muted">{unownedCount}</span>}
</button>
)}
</div>
)}
</div>
{items === null ? (
<SkeletonGrid label={t("loading")} />
) : items.length === 0 && tab === "unowned" ? (
<p className="rounded-lg border border-dashed border-border px-4 py-6 text-center text-xs text-fg-muted">
{t("unowned.empty")}
</p>
) : items.length === 0 ? (
<EmptyState
hub={tab === "hub"}
@@ -755,6 +857,11 @@ function ArtifactCard({
{t(hubOnly ? "hubOnly" : "shared")}
</span>
)}
{a.auto && (
<span className="badge self-start" title={t("autoHint")}>
{t("auto")}
</span>
)}
{a.description && (
<p className="line-clamp-2 text-xs leading-relaxed text-fg-secondary">{a.description}</p>
)}
+4
View File
@@ -73,6 +73,7 @@ import { ToolProfilesSettings } from "../components/settings/ToolProfilesSetting
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
import { ChangesPanelSettings } from "../components/settings/ChangesPanelSettings";
import { useAiTools } from "../hooks/useAiTools";
import {
rankSettings,
@@ -424,6 +425,9 @@ export function SettingsHub({ section }: { section?: string } = {}) {
<GitIdentitySettings />
</section>
)}
<div id="changes-panel" className="scroll-mt-24">
<ChangesPanelSettings />
</div>
<div id="worktrees" className="scroll-mt-24">
<WorktreeVisibilitySettings />
</div>
+5 -3
View File
@@ -3,8 +3,8 @@
* @description The Users tab of the Administration page (`/admin`, which
* `/users` redirects to — `Administration.tsx`), server mode only and
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
* role, state, workspaces, sessions, the tokens/$ it used in the last 7 and
* 30 days (`GET /api/users`) and how much disk its home folder takes
* role, state, workspaces, sessions, the tokens/$ it used today (the
* browser's local day) and in the last 7 and 30 days (`GET /api/users`) and how much disk its home folder takes
* (`GET /api/users/storage`, measured in the background — polled while the
* server says `measuring`; Refresh re-measures), the audit log (`GET /api/users/audit`), and — for
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
@@ -214,13 +214,14 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
)}
<div className="card overflow-x-auto">
<table className="w-full min-w-[860px]" data-testid="users-table">
<table className="w-full min-w-[960px]" data-testid="users-table">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("name")}</th>
<th className={th}>{t("role")}</th>
<th className={`${th} text-right`}>{t("workspaces")}</th>
<th className={`${th} text-right`}>{t("sessions")}</th>
<th className={`${th} text-right`}>{t("today")}</th>
<th className={`${th} text-right`}>{t("last7d")}</th>
<th className={`${th} text-right`}>{t("last30d")}</th>
<th className={`${th} text-right`}>{t("storage")}</th>
@@ -299,6 +300,7 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
{u.sessions}
</td>
<UsageCell usage={u.today ?? { cost: 0, tokens: 0 }} />
<UsageCell usage={u.last7d} />
<UsageCell usage={u.last30d} />
<StorageCell
+111 -17
View File
@@ -69,8 +69,10 @@ import {
ChartColumn,
ChevronDown,
ChevronRight,
GitBranch,
} from "lucide-react";
import { api } from "../lib/api";
import { api, type GitSource } from "../lib/api";
import { useInnerReposMode } from "../lib/innerReposPref";
import { usePermissions } from "../components/AuthGate";
import type {
CwdSuggestion,
@@ -940,6 +942,43 @@ export function Workspace() {
);
}, [panelLaneId, takeGitFolders]);
const folders = gitFolders?.laneId === panelLaneId ? gitFolders.items : [];
// The repos INSIDE the workspace folder (a parent folder of several repos):
// one section each, read with `?repo=` (`GET /lanes/:id/repos`, re-read
// with the panel's own refresh and every minute, so a clone shows up) —
// unless the account chose the folder's own git only (Settings → Git →
// Changes panel), which neither walks the folder nor shows them.
const innerMode = useInnerReposMode();
const [innerRepos, setInnerRepos] = useState<{
laneId: number;
root: boolean;
items: { path: string; name: string }[];
} | null>(null);
const loadInnerRepos = useCallback((laneId: number, refresh = false) => {
api.lanes.repos(laneId, refresh).then(
(v) =>
setInnerRepos((prev) =>
prev?.laneId === laneId &&
prev.root === v.root &&
prev.items.map((r) => r.path).join("\n") === v.items.map((r) => r.path).join("\n")
? prev
: { laneId, root: v.root, items: v.items }
),
() => setInnerRepos({ laneId, root: true, items: [] })
);
}, []);
useEffect(() => {
if (panelLaneId === null || innerMode === "own") return;
loadInnerRepos(panelLaneId);
const timer = setInterval(() => loadInnerRepos(panelLaneId, true), 60_000);
return () => clearInterval(timer);
}, [panelLaneId, loadInnerRepos, innerMode]);
const inner = innerMode === "all" && innerRepos?.laneId === panelLaneId ? innerRepos : null;
const repos = inner?.items ?? [];
// A folder that is no repo itself but holds some: its own section would only
// say "not a git repository", so the repos stand alone.
const showRoot = !inner || inner.root || repos.length === 0;
// A file the chat asks for lies in the inner repo whose path prefixes it.
const repoOf = (p: string) => repos.find((r) => p.startsWith(`${r.path}/`));
// Sections folded shut ("" = the workspace folder, else the folder's id).
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
// Right-click on a section: close extra folders — which REMOVES them from the
@@ -990,15 +1029,19 @@ export function Workspace() {
null
);
const panelLaneRef = useRef<number | null>(null);
const repoOfRef = useRef(repoOf);
repoOfRef.current = repoOf;
panelLaneRef.current = panelAt?.laneId ?? null;
useEffect(() => {
const onOpen = (e: Event) => {
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
if (laneId !== panelLaneRef.current) return;
// Its path is the workspace folder's: unfold that section.
// Its path is the workspace folder's: unfold that section (or the
// inner repo's that holds it).
const repo = repoOfRef.current(path);
setFoldedSources((prev) => {
const next = new Set(prev);
next.delete("");
next.delete(repo ? `repo:${repo.path}` : "");
return next;
});
setChangesOpen(true);
@@ -1612,9 +1655,43 @@ export function Workspace() {
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
{panelAt &&
[
{ key: "", source: undefined as number | undefined },
...folders.map((f) => ({ key: String(f.id), source: f.id })),
].map(({ key, source }, i) => {
...(showRoot
? [
{
key: "",
source: undefined as GitSource | undefined,
name:
currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() ||
currentLane.cwd,
root: undefined as string | undefined,
},
]
: []),
...repos.map((r) => ({
key: `repo:${r.path}`,
source: r.path as GitSource,
name: r.name,
root: `${currentLane.cwd.replace(/[\\/]+$/, "")}/${r.path}`,
})),
...folders.map((f) => ({
key: String(f.id),
source: f.id as GitSource,
name: f.name,
root: f.path,
})),
].map(({ key, source, name, root: dragRoot }, _i, sections) => {
// The chat's file request goes to the section holding it.
const req =
source === undefined
? changesRequest
: typeof source === "string" && changesRequest
? changesRequest.path.startsWith(`${source}/`)
? {
path: changesRequest.path.slice(source.length + 1),
nonce: changesRequest.nonce,
}
: null
: null;
const panel = (
<LaneChangesPanel
// A new tree is a new panel: no read for the previous
@@ -1627,8 +1704,8 @@ export function Workspace() {
full={changesFull}
wide={changesRatio(changesSize) >= 0.45}
onDirtyChange={dirtyFor(key)}
openRequest={source ? null : changesRequest}
filesChanged={source ? 0 : filesChanged}
openRequest={req}
filesChanged={source === undefined ? filesChanged : 0}
// A sibling worktree forks from the lane's SOURCE repo
// when it has one (this lane is itself a worktree of
// it); an adopted lane is its own repo.
@@ -1641,23 +1718,20 @@ export function Workspace() {
}}
/>
);
// No extra folders: the one panel, as it always was.
if (!folders.length) return panel;
// One repo only: the one panel, as it always was.
if (sections.length === 1) return panel;
// Else every repo is a section, stacked like VS Code's
// Source Control; the open ones share the height. A
// folded one stays mounted (CSS), so an unsaved edit in
// it survives.
const folded = foldedSources.has(key);
const name = source
? folders[i - 1]!.name
: currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd;
return (
<section
key={key}
data-testid="panel-git-source"
// A row dragged from an extra folder lies outside
// the pane's folder: `dragProjectPath` makes it absolute.
data-drag-root={source ? folders[i - 1]!.path : undefined}
data-drag-root={dragRoot}
className={`flex flex-col border-b border-border-light ${
folded ? "shrink-0" : "min-h-0 flex-1"
}`}
@@ -1680,7 +1754,12 @@ export function Workspace() {
) : (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
)}
<span className="truncate">{name}</span>
{typeof source === "string" && (
<GitBranch className="h-3 w-3 shrink-0 text-fg-muted" />
)}
<span className="truncate" title={dragRoot}>
{name}
</span>
</button>
<div className={folded ? "hidden" : "flex min-h-0 flex-1 flex-col"}>
{panel}
@@ -1695,7 +1774,18 @@ export function Workspace() {
onClose={sourceCtx.close}
testId="panel-git-source-menu"
>
{sourceCtx.menu.item !== "" && (
{innerMode === "all" && (
<MenuItem
testId="panel-git-source-rescan"
onSelect={() => {
sourceCtx.close();
loadInnerRepos(panelAt.laneId, true);
}}
>
{tLanes("changes.rescanRepos")}
</MenuItem>
)}
{/^\d+$/.test(sourceCtx.menu.item) && (
<MenuItem
testId="panel-git-source-close"
onSelect={() => {
@@ -1777,7 +1867,11 @@ export function Workspace() {
</span>
</button>
{panelAt &&
[undefined, ...folders.map((f) => f.id)].map((source) => (
[
...(showRoot ? [undefined] : []),
...repos.map((r): GitSource => r.path),
...folders.map((f): GitSource => f.id),
].map((source) => (
<LaneGitRailCounts
key={source ?? ""}
laneId={panelAt.laneId}
@@ -101,6 +101,8 @@ vi.mock("../../lib/api", async (importOriginal) => {
: [],
})
),
// The repos inside the workspace folder (`?repo=`): none by default.
repos: vi.fn().mockResolvedValue({ root: true, items: [], truncated: false }),
// Closing a folder's section removes it from the workspace.
removeSource: r({
sources: [{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false }],
@@ -330,6 +332,79 @@ describe("Workspace — the Changes panel's width", () => {
expect(screen.queryByTestId("panel-git-source")).toBeNull();
});
it("neither walks a parent folder nor shows its repos until the account turns it on", async () => {
vi.mocked(api.lanes.repos).mockClear();
vi.mocked(api.lanes.repos).mockResolvedValue({
root: false,
items: [{ path: "api", name: "api" }],
truncated: false,
});
render(
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
<Workspace />
</MemoryRouter>
);
await settle();
const rail = screen.queryByTestId("lane-changes-expand");
if (rail) {
fireEvent.click(rail);
await settle();
}
expect(api.lanes.repos).not.toHaveBeenCalled();
expect(screen.queryByTestId("panel-git-source")).toBeNull();
vi.mocked(api.lanes.repos).mockResolvedValue({ root: true, items: [], truncated: false });
});
it("gives each repo inside a parent folder its own section, without the folder's own", async () => {
// Settings → Git → Changes panel: show the repositories inside.
window.localStorage.setItem("agenthub.changes.innerRepos", "all");
vi.mocked(api.lanes.repos).mockResolvedValue({
root: false,
items: [
{ path: "api", name: "api" },
{ path: "apps/web", name: "apps/web" },
],
truncated: false,
});
vi.mocked(api.lanes.git).mockClear();
render(
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
<Workspace />
</MemoryRouter>
);
await settle();
const rail = screen.queryByTestId("lane-changes-expand");
if (rail) {
fireEvent.click(rail);
await settle();
}
const sections = screen.getAllByTestId("panel-git-source");
expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual([
"api",
"apps/web",
]);
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 2, repo: "apps/web" });
// An inner repo is the folder's own: its menu rescans, never closes it.
fireEvent.contextMenu(sections[1]!.querySelector("button")!);
expect(screen.queryByTestId("panel-git-source-close")).toBeNull();
fireEvent.click(screen.getByTestId("panel-git-source-rescan"));
expect(api.lanes.repos).toHaveBeenLastCalledWith(2, true);
// A chat's file inside an inner repo opens in that repo's section.
fireEvent.click(sections[1]!.querySelector("button")!);
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "false");
act(() => {
openInChanges(2, "/workspace/beta", "/workspace/beta/apps/web/src/x.ts");
});
await settle();
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "true");
expect(api.lanes.gitFile).toHaveBeenCalledWith(
{ lane: 2, repo: "apps/web" },
"src/x.ts",
"work"
);
vi.mocked(api.lanes.repos).mockResolvedValue({ root: true, items: [], truncated: false });
});
it("is one plain panel for a workspace with no extra folders", async () => {
render(
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
@@ -123,6 +123,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
lanes: {
// The Changes panel's folder picker reads the workspace's extra folders.
sources: vi.fn().mockResolvedValue({ sources: [] }),
repos: vi.fn().mockResolvedValue({ root: true, items: [], truncated: false }),
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/lanes");
return { lanes: lanesToReturn, counts: countsToReturn };
@@ -7244,7 +7244,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.20 (rebuild client to match)
UI build v2.8.23 (rebuild client to match)
</span>
</p>
</div>
+44 -8
View File
@@ -89,7 +89,7 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
@@ -395,13 +395,17 @@ Clone repository** (`client/src/components/lanes/CloneHereDialog.tsx`) clones
into the folder it has open — of the user's own home or of a workspace's own
folder, never a session worktree, an extra folder or another user's home —
through `POST /api/lanes/clone` below with that folder as `parent`, so every
check of that route applies, and opens a workspace on the clone.
check of that route applies, and opens a workspace on the clone. It sends a
`progressToken` and shows the queue place / git progress while the clone runs;
closing the dialog then only hides it behind a progress chip (click to reopen)
— the request keeps going and its outcome arrives as a toast.
#### Clone a repository into a new workspace
```
POST /api/lanes/clone Body: { url, parent, name?, title?, tool?, credentials?: { username, password, remember? }, partial?, progressToken? }
→ 201 { lane, created: true, path }
→ 200 { lane, created: false, path } (the user's own workspace already at that folder + tool, its folder deleted since)
GET /api/lanes/clone/progress/:token
→ 200 { state: queued|running|done|failed, position, running, waiting, limit,
phase: remote|receiving|resolving|checkout|other|null, label, percent, detail }
@@ -967,6 +971,34 @@ folder below its repo root) applies to it as to a workspace folder, and
`GET /git` reports `base` as that repo's own default branch. A folder that is
not a git repo answers `{available: false}`.
#### Repos inside a parent workspace folder (`?repo=`)
A workspace whose folder holds several repos side by side
(`~/work/shop/{api,web,apps/admin}`) lists them with:
```http
GET /api/lanes/:id/repos[?refresh=1]
→ { "root": false, "items": [{ "path": "api", "name": "api" }, …], "truncated": false }
```
`server/lib/nested-repos.js` walks the disk only (no git call): every directory
up to 2 levels below the workspace folder holding a `.git` entry (a directory,
or a submodule's / linked worktree's file), never through a symlink, a dot
folder or `node_modules`/`vendor`/`dist`/`build`/`target`, never inside a repo
it already found; at most 50 repos and 5000 directory entries. Cached 30 s per
folder; `?refresh=1` reads again. `root` is whether the workspace folder
itself is in a repo. The Changes panel calls it only for an account that
turned on Settings → Git → Changes panel (`agenthub.changes.innerRepos` =
`all`; off by default).
Every `/api/lanes/:id/git…` and `/fs…` route also takes `?repo=<path>` (the
`path` above): it is accepted only when that walk found it — re-walked at most
every 2 s for a name the cache lacks — otherwise `404 ENOREPO`, never a fall
back to the workspace folder. Like an extra folder, the repo is its own
boundary under every guard above, and `GET /git` reports its own default
branch as `base`. The Changes panel shows one section per repo (the workspace
folder's own section only when it is itself a repo).
#### Remove a session's worktree
```http
@@ -2925,7 +2957,7 @@ GET /api/run/interrupted {items} — sessions whose pane died under
POST /api/run/interrupted/dismiss {sessionIds: string[]} (≤ 100) → {dismissed}: stop offering them; resumes nothing. 400 EBADFIELD otherwise
GET /api/run/files?cwd=&q=&dirs=1 Fuzzy file search inside cwd for the @-file autocomplete (dirs=1 adds folders as `dir/`, for the + menu)
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path (a `claude` pane: an image as its bare path, each in a paste of its own, which Claude Code shows as `[Image #n]`; any other file as an `@` mention)
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB; ≤2 GB from a loopback browser outside server mode, streamed to disk — Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here. Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body, and close the connection; of several files only the refused ones are lost
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB; ≤2 GB from a loopback browser outside server mode, streamed to disk — Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here, and an image PASTED into a `claude` pane by a loopback browser of a local-mode dashboard, which is sent as ^V so `claude` reads the desktop clipboard itself (Settings → Appearance → **Paste images natively**, on by default, per browser — off for an SSH tunnel). Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body, and close the connection; of several files only the refused ones are lost
POST /api/run/local-paths {paths:[<absolute>…]} (1–100) → {sizes:[bytes|null…]}: each path's size when it is a regular file on this machine, else null. The composer and the terminal use a dropped file's own `file://` path only when every size matches the dropped bytes' — "localhost" in the address bar is no proof (an SSH tunnel's browser is on another machine). 403 EREMOTE in server mode (a pane runs as an account that cannot read the host's files) and to anything but a loopback socket without X-Forwarded-For/Forwarded; 400 EBADFIELD otherwise
GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked — for an admin, then the host's, where their host-folder workspaces drop); only a name drop-image/drop-file made → 404 otherwise
GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
@@ -2976,7 +3008,7 @@ GET /api/ai-tools/:id/icon The tool's favicon, fetched once by the se
Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, proxyCapable: bool }` — flags only, never the arg arrays. Each tool also carries `modelFlag` (the CLI takes a model: Claude, Codex, or a catalog `model: { flag, vendor? }` field) and `defaultModel` (what a new run starts with, `null` = the CLI's own; a `PUT` with `""`/`null` clears it, an id that is not a plain model name is `400 EBADMODEL`). The config files are only those a built-in or release catalog file lists in `config` (`~/`-relative, no `..`, no credential-looking name); a read or save answers `404 ENOFILE` (no such index) / `ENOENT` (missing — never created), `403 ESECRET` (the name, or a symlink target's, looks like a credential), `413 ETOOBIG` (> 1 MB), `415 EBINARY`, `400 EBADSYNTAX` (JSON/JSONC that does not parse), `409 ESTALE` (the file is no longer at `baseSha`); a save backs the old text up under `<data dir>/ai-tool-config-backups/<id>/` and is same-origin only. `GET /:id/models` reads each tool's own source: Codex's `$CODEX_HOME/models_cache.json` (listed entries by priority — nothing else from that file), Claude's aliases named after the newest priced version plus the priced pinned versions, a catalog tool's vendor-priced ids plus the models its own sessions used; `flag: false` and `[]` for a tool with no model flag, `404 ENOTOOL` for an unknown id. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`. Each tool also carries `install` (`{kind:"npm", package}` or `{kind:"script", url}`, else null) and `update` (`{kind:"npm"}`, `{kind:"script"}`, `{kind:"self", args}`, else null), and the body carries `catalog: { source: "builtin"|"remote", tag, fetchedAt }` — `remote` while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with `AGENTHUB_AI_TOOLS_SYNC=0`).
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`, plus `--prefix ~/.local` when npm's global prefix is not writable by the server's account — a system-wide Node; a tool under `~/.local/lib/node_modules` is updated the same way); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/<id>.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools.
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`, plus `--prefix ~/.local` when npm's global prefix is not writable by the server's account — a system-wide Node; a tool under `~/.local/lib/node_modules` is updated the same way); a script is downloaded by the server (https, same site as the tool's homepage — or, after a redirect, exactly the catalog entry's `install.redirect` URL, e.g. omp's `omp.sh/install` → its GitHub raw script —, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/<id>.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools.
### AI tool login profiles
@@ -3087,7 +3119,7 @@ A snapshot freezes a session as it is at that moment into one immutable `session
### Artifacts hub
```http
GET /api/artifacts?scope=all|mine|hub&kind=page|file|skill|plugin&q= {items} — each with `installed` (default scope `all`)
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed` (default scope `all`)
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
@@ -3107,17 +3139,21 @@ GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, n
POST /api/artifacts/:id/install {replace?} → {installed, replaced?}
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded}
POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin
POST /api/artifacts/:id/decline → {id, declined} — the suggested user
POST /api/artifacts/:id/assign {userId} → {item} — admin
```
What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`):
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish is captured from its PostToolUse hook (the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned.
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish — or a Claude Docs `batch` that creates a doc (`container.create`; its title is the doc's name, later batches are skipped) — is captured from its PostToolUse hook (a doc's own `frame.artifactUrl`; for a publish the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned, with `suggested_owner_id` set to the owner of the workspace whose folder holds the session's `cwd` (else, under S3, the member whose home it is).
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `*token*`…) `403 ESECRET`.
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
- **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way.
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it.
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**. The page shows one list (`scope=all`: own rows plus shared ones) with an **All · My library · Hub** filter. `scope=mine` is the caller's own library rows plus the shared skills/plugins of OTHER users that the caller has installed (`installed: true`, `mine: false`); in server mode the page splits EVERY tab by `mine`, others' section first — **All** into *Shared by others* / *Mine*, **Hub** into *Shared by others* / *Shared by me*, **My library** into *Installed from others* / *Mine* — and each section pages 12 cards at a time (client-side). Deleting a SHARED row of your own only takes it out of your library (`in_library = 0`, `inLibrary: false`, `{kept: true}`; it leaves `scope=mine` and shows as **Hub only**); unsharing such a row then deletes it (`PATCH` answers `{item, removed: true}`). Deleting an unshared row, or an admin deleting someone else's, deletes it outright.
- **Unowned** — server mode: a row with no owner (`unowned: true`). `scope=unowned` lists every one for an admin and, for anyone else, those suggested to them (`suggestedForMe: true`; such a row is also visible to them, `canSeeId`). **claim** makes the suggested user — or an admin — its owner; **decline** drops the suggestion (the row stays for an admin); **assign** `{userId}` is an admin giving it to an enabled user (`400 EBADFIELD` otherwise). All three answer `409 EOWNED` once it has an owner, `400 ELOCALMODE` in local mode, and are written to `audit_log`. The page shows the tab **Unowned** to an admin and **Is this yours?** to a user with suggestions.
- **raw** — only a file the snapshot recorded (`404 ENOFILE` otherwise), never a request path; served with `Content-Security-Policy: …; sandbox` and `nosniff`, unknown types as an attachment.
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list. Share, unshare, a shared row's delete and every install or uninstall by someone else are written to `audit_log`.
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page offers **Uninstall** only in the card's ⋯ menu (and its right-click menu); there is no Reinstall — a newer version is uninstalled and installed again.
@@ -3218,7 +3254,7 @@ POST /api/updates/check
POST /api/updates/apply
```
Release-based update detection (`server/lib/update-check.js`). `status` and `check` answer the same object — `{ git_repo, update_available, install_kind: "checkout"|"plugin", local_version, latest_release, latest_release_url, latest_release_notes, release_check, commits_behind, commits_behind_release, manual_command, situation, last_plugin_update, repo_root, … }` (`repo_root` is `null` for a developer); `check` also broadcasts it as `update_status`. `apply` (admin-only in server mode) picks its mode by install kind, never from the request: a fast-forwardable checkout merges the release `--ff-only`, rebuilds, answers `{ …, applied: true }` and then restarts the server; a plugin-cache install with a newer release hands off to the detached plugin-update chain → `{ …, applied: true, mode: "plugin" }`. Nothing to apply is `409 UPDATE_NOT_APPLICABLE` (`reason`: `up_to_date`, `plugin_managed`, `not_fast_forwardable`). `500 UPDATE_STATUS_FAILED` / `UPDATE_CHECK_FAILED` / `UPDATE_APPLY_FAILED`.
Release-based update detection (`server/lib/update-check.js`). `status` and `check` answer the same object — `{ git_repo, update_available, install_kind: "checkout"|"plugin", local_version, latest_release, latest_release_url, latest_release_notes, release_check, commits_behind, commits_behind_release, manual_command, situation, last_plugin_update, plugin_update_progress, repo_root, … }` (`plugin_update_progress` is `{ step, index, total, started_at }` while a plugin-update chain runs — `marketplace_update` → `plugin_update` → `bootstrap`, from `plugin-update-progress.json` in the runtime dir — else `null`) (`repo_root` is `null` for a developer); `check` also broadcasts it as `update_status`. `apply` (admin-only in server mode) picks its mode by install kind, never from the request: a fast-forwardable checkout merges the release `--ff-only`, rebuilds, answers `{ …, applied: true }` and then restarts the server; a plugin-cache install with a newer release hands off to the detached plugin-update chain → `{ …, applied: true, mode: "plugin" }`. Nothing to apply is `409 UPDATE_NOT_APPLICABLE` (`reason`: `up_to_date`, `plugin_managed`, `not_fast_forwardable`). `500 UPDATE_STATUS_FAILED` / `UPDATE_CHECK_FAILED` / `UPDATE_APPLY_FAILED`.
### Import history
+1 -1
View File
@@ -513,7 +513,7 @@ Managed through the `/api/remote-sources/*` routes; sync/status changes are broa
### token_usage_hourly
The same buckets as `token_usage` (session, model, speed, inference_geo, service_tier, the eight count columns and the five `long_*_tokens` subsets, no baselines), split by `hour` — the UTC hour `YYYY-MM-DDTHH` a Claude transcript message was written in. The hook writer fills it from `TranscriptCache`'s `tokensByHour` on every transcript-bearing event (`writeHourly` in `server/lib/hourly-usage.js`, `stmts.upsertHourlyUsage`, MAX per column so a rewritten transcript never takes an hour back), and the server back-fills sessions that have tokens but no hourly rows a few seconds after each start (`backfillHourly`, one transcript per tick). Only the daily split of `GET /api/pricing/cost` reads it; `token_usage` stays the total, and what it holds beyond these rows is booked to the session's start date. Cascades with its session.
The same buckets as `token_usage` (session, model, speed, inference_geo, service_tier, the eight count columns and the five `long_*_tokens` subsets, no baselines), split by `hour` — the UTC hour `YYYY-MM-DDTHH` a Claude transcript message was written in. The hook writer fills it from `TranscriptCache`'s `tokensByHour` on every transcript-bearing event — the session's own transcript plus its sub-agents' (`<session>/subagents/agent-*.jsonl`, `server/lib/subagent-usage.js`; the same sum goes into `token_usage`, so a sub-agent on the session's own model is counted too; Workflow agents stay under their own `workflow` tier) — (`writeHourly` in `server/lib/hourly-usage.js`, `stmts.upsertHourlyUsage`, MAX per column so a rewritten transcript never takes an hour back), and the server back-fills sessions that have tokens but no hourly rows a few seconds after each start (`backfillHourly`, one transcript per tick). Only the daily split of `GET /api/pricing/cost` reads it; `token_usage` stays the total, and what it holds beyond these rows is booked to the session's start date. Cascades with its session.
---
+2 -1
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -160,7 +160,8 @@ Workspaces made before server mode, or by the host account, have none.
An account's last login is kept in `users.last_login_at`, so it still shows
after the login itself has ended.
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
sessions, and the tokens and $ it used in the last 7 and 30 days — the
sessions, and the tokens and $ it used today (the browser's local day) and in the last 7 and 30 days
(booked like the cost routes' daily split, so Codex/omp/Antigravity and sub-agent usage count) — the
**Administration** page in the sidebar (`/admin`; the old `/users` link
redirects there) shows it on its **Users** tab, with the audit log under it.
- `GET /api/host` (admin/analyst, `/host` is staff-only) is Administration's
@@ -738,7 +739,7 @@ What runs as the member, and how:
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), then **More keys** (named keys for extra accounts) and **Other hosts** (advanced); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — clone `ssh://<name>/v1/repos/<repo>`; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
- the workspace prompt and its context files;
- the AI Proxy settings: no key, only that pane's own token for the gateway;
+2 -2
View File
@@ -8,8 +8,8 @@ One place to keep what agents PRODUCE, and (in server mode) to share it:
| Kind | What it is | Where it comes from |
|---|---|---|
| `page` | A claude.ai Artifact a session published (`claude.ai/…/artifact/…`) | the `Artifact` tool's PostToolUse hook, or a link pasted by hand |
| `file` | A file an agent wrote (HTML, Markdown, PDF, image, CSV, Office…) | Write/Edit/NotebookEdit hook events, kept on the user's pin; or uploaded from the computer |
| `page` | A claude.ai Artifact a session published (`claude.ai/…/artifact/…`) | the `Artifact` tool's (or a Claude Docs doc-creating `batch`'s) PostToolUse hook, or a link pasted by hand |
| `file` | A file an agent wrote (HTML, Markdown, PDF, image, CSV, Office…) | Write/Edit/NotebookEdit hook events, kept on the user's pin; a pptx/docx/xlsx/pdf/html the session created or changed, captured at its Stop (`auto`); or uploaded from the computer |
| `skill` | A Claude Code skill folder (`SKILL.md` + files) | the user's own `~/.claude/skills/<name>` or a workspace's `.claude/skills/<name>`; or uploaded (a Markdown file, files, a folder, a .zip/.skill — several skills at once too) or written in the dialog, under a name the user chooses |
| `plugin` | A plugin by marketplace source + name (no files) | picked from the list a marketplace link answers (`POST /api/artifacts/plugin/inspect`), or typed as `name@marketplace` |
+226 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.8.20
version: 2.8.23
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ĩ
@@ -12039,6 +12039,11 @@ paths:
required: false
schema:
type: integer
- name: repo
in: query
required: false
schema:
type: string
responses:
'200':
description: The file's bytes, as an attachment.
@@ -12082,6 +12087,11 @@ paths:
required: false
schema:
type: integer
- name: repo
in: query
required: false
schema:
type: string
requestBody:
required: true
content:
@@ -12143,6 +12153,11 @@ paths:
required: false
schema:
type: integer
- name: repo
in: query
required: false
schema:
type: string
responses:
'200':
description: The suggested message
@@ -14704,6 +14719,14 @@ paths:
type: integer
sessions:
type: integer
today:
type: object
description: Spent since the viewer's local midnight (`tz_offset`).
properties:
cost:
type: number
tokens:
type: integer
last7d:
type: object
properties:
@@ -17052,8 +17075,10 @@ paths:
schema:
type: string
enum:
- all
- mine
- hub
- unowned
- name: kind
in: query
schema:
@@ -18286,6 +18311,49 @@ paths:
description: ENOLANE — workspace not found.
'409':
description: ELIVE (a pane holds the session), ELOCKED, or EGIT (git refused, e.g. a dirty tree).
/api/lanes/{id}/repos:
get:
tags:
- Lanes
summary: The git repositories inside a workspace folder
description: 'For a parent folder holding several repos (server/lib/nested-repos.js): every directory up to 2 levels below the workspace folder that holds a `.git` entry, never through a symlink, a dot folder or `node_modules`, and never inside a repo already found; at most 50 repos and 5000 directory entries read. Disk reads only (no git call), cached 30 s; `?refresh=1` reads again. Each `path` (relative, `/`-separated) is what the /git and /fs routes take as `?repo=` — a value this walk did not produce answers 404 ENOREPO. `root` says whether the workspace folder itself is in a repo. A missing folder is an empty list.'
operationId: getLaneRepos
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: refresh
in: query
required: false
schema:
type: string
enum:
- '1'
responses:
'200':
description: '{root, items: [{path, name}], truncated}.'
content:
application/json:
schema:
type: object
properties:
root:
type: boolean
items:
type: array
items:
type: object
properties:
path:
type: string
name:
type: string
truncated:
type: boolean
'404':
description: ENOLANE — workspace not found.
/api/lanes/{id}/sources:
get:
tags:
@@ -22135,6 +22203,163 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/artifacts/{id}/claim:
post:
tags:
- Artifacts
summary: Take an unowned artifact
operationId: claimArtifact
description: Server mode. The user the row is suggested to (the owner of the workspace or home its session ran in), or an admin, becomes its owner. Audited. Same-origin only.
parameters:
- name: id
in: path
required: true
description: Artifact id.
schema:
type: string
responses:
'200':
description: Claimed.
content:
application/json:
schema:
type: object
properties:
item:
type: object
'400':
description: ELOCALMODE.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: 'EFORBIDDEN: not the suggested user.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOTFOUND.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: 'EOWNED: it already has an owner.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/artifacts/{id}/decline:
post:
tags:
- Artifacts
summary: Say an unowned artifact offered to you is not yours
operationId: declineArtifact
description: Server mode. Drops the suggestion; the row stays unowned for an admin to place. Audited. Same-origin only.
parameters:
- name: id
in: path
required: true
description: Artifact id.
schema:
type: string
responses:
'200':
description: Declined.
content:
application/json:
schema:
type: object
properties:
id:
type: integer
declined:
type: boolean
'400':
description: ELOCALMODE.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: 'EFORBIDDEN: not the suggested user.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOTFOUND.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: EOWNED.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/artifacts/{id}/assign:
post:
tags:
- Artifacts
summary: Give an unowned artifact to a user (admin)
operationId: assignArtifact
description: Server mode, admin only. Body `{userId}` names an enabled user. Audited. Same-origin only.
parameters:
- name: id
in: path
required: true
description: Artifact id.
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- userId
properties:
userId:
type: integer
responses:
'200':
description: Assigned.
content:
application/json:
schema:
type: object
properties:
item:
type: object
'400':
description: ELOCALMODE / EBADFIELD.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: 'EFORBIDDEN: not an admin.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOTFOUND.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: EOWNED.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/settings/import:
post:
tags:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.20",
"version": "2.8.23",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.20",
"version": "2.8.23",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.20",
"version": "2.8.23",
"bin": {
"agenthub": "bin/agenthub.js"
},
+19 -20
View File
@@ -1538,26 +1538,22 @@ function importSession(dbModule, session) {
backfilled = true;
}
// Reconcile token usage. The earlier importer dropped subagent tokens
// entirely, so any session with subagent JSONLs has under-counted totals.
// replaceTokenUsage's baseline-shift logic guarantees this can never
// reduce a session's totals — at worst it's a no-op.
if (
session.parsedSubagents &&
session.parsedSubagents.some(
(s) =>
s.tokensByModel &&
Object.values(s.tokensByModel).some(
(t) => (t.input || 0) + (t.output || 0) + (t.cacheRead || 0) + (t.cacheWrite || 0) > 0
)
)
) {
const written = writeSessionTokens(
dbModule,
session.sessionId,
combineSessionTokens(session)
// Reconcile token usage on every pass. A session first imported while its
// transcript was still being written (a `claude -p` run with no hooks is
// seen the moment its file appears) was stored with the usage of that
// moment — often none — and its sub-agents' tokens arrive later still.
// replaceTokenUsage keeps the highest total ever written, so this can only
// raise a bucket, never inflate it; it counts as a backfill only when the
// session's total actually grew.
{
const totalOf = db.prepare(
`SELECT COALESCE(SUM(input_tokens + baseline_input + output_tokens + baseline_output
+ cache_read_tokens + baseline_cache_read + cache_write_tokens + baseline_cache_write), 0) AS n
FROM token_usage WHERE session_id = ?`
);
if (written > 0) backfilled = true;
const before = totalOf.get(session.sessionId).n;
writeSessionTokens(dbModule, session.sessionId, combineSessionTokens(session));
if (totalOf.get(session.sessionId).n > before) backfilled = true;
}
return backfilled ? { skipped: false, backfilled: true } : { skipped: true };
@@ -2129,6 +2125,9 @@ async function syncDefaultProjects(dbModule, options = {}) {
*/
async function reconcileTokens(dbModule, options = {}) {
const onProgress = typeof options.onProgress === "function" ? options.onProgress : () => {};
// `exact: false` only raises (replaceTokenUsage's high-water mark): a repair
// that adds what was never counted without touching anything already right.
const exact = options.exact !== false;
const counters = { reconciled: 0, sessionsTouched: 0, modelsWritten: 0, missingFiles: 0 };
if (!fs.existsSync(getProjectsDir())) return counters;
@@ -2164,7 +2163,7 @@ async function reconcileTokens(dbModule, options = {}) {
const tx = dbModule.db.transaction((batch) => {
for (const { sessionId, tokens } of batch) {
const written = writeSessionTokens(dbModule, sessionId, tokens, { exact: true });
const written = writeSessionTokens(dbModule, sessionId, tokens, { exact });
if (written > 0) {
counters.sessionsTouched++;
counters.modelsWritten += written;
+42 -7
View File
@@ -28,6 +28,10 @@
* refused on STDOUT (`bootstrap: locked`), and execFileSync's own message is
* only "Command failed: <argv>".
*
* While a step runs, `plugin-update-progress.json` beside it names that step
* (`progress`), so the dashboard can say how far the chain got rather than
* only that it is busy; a final verdict removes it.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -43,6 +47,10 @@ const [, , marketplace, plugin, oldRoot, runtimeDirArg] = process.argv;
// main() — which a require never runs — writes these.
const RESULT_FILE = path.join(runtimeDirArg || "", "plugin-update.json");
const LOG_FILE = path.join(runtimeDirArg || "", "plugin-update.log");
const PROGRESS_FILE = path.join(runtimeDirArg || "", "plugin-update-progress.json");
/** The chain's steps, in order, as `progress` reports them. */
const STEPS = ["marketplace_update", "plugin_update", "bootstrap"];
// Names come from the local cache PATH, never from an HTTP body, but they still
// reach an argv — so they are re-validated here rather than trusted across the
@@ -59,13 +67,34 @@ function log(line) {
}
}
function record(result) {
/** Every verdict but the pre-written handover one (`final: false`) ends the
* chain, so its progress record goes with it. */
function record(result, { final = true } = {}) {
try {
fs.mkdirSync(runtimeDirArg, { recursive: true });
fs.writeFileSync(
RESULT_FILE,
JSON.stringify({ ...result, finished_at: new Date().toISOString() }, null, 2)
);
if (final) fs.rmSync(PROGRESS_FILE, { force: true });
} catch {
/* best effort */
}
}
/** Say which step is running now — a courtesy for the UI, never a verdict. */
function progress(step) {
try {
fs.mkdirSync(runtimeDirArg, { recursive: true });
fs.writeFileSync(
PROGRESS_FILE,
JSON.stringify({
step,
index: STEPS.indexOf(step) + 1,
total: STEPS.length,
started_at: new Date().toISOString(),
})
);
} catch {
/* best effort */
}
@@ -182,6 +211,7 @@ function main() {
return;
}
progress("marketplace_update");
try {
run(claude, ["plugin", "marketplace", "update", marketplace], 300_000);
} catch (err) {
@@ -189,6 +219,7 @@ function main() {
return;
}
progress("plugin_update");
try {
run(claude, ["plugin", "update", `${plugin}@${marketplace}`, "-y"], 600_000);
} catch (err) {
@@ -219,12 +250,16 @@ function main() {
// `last_plugin_update: null` for an update that had in fact succeeded. A
// pre-written record is overwritten by the real outcome when this survives,
// and is the outcome when it does not.
record({
ok: true,
step: "bootstrap",
plugin_root: newRoot,
note: "the new plugin version is installed; the server is being handed over",
});
record(
{
ok: true,
step: "bootstrap",
plugin_root: newRoot,
note: "the new plugin version is installed; the server is being handed over",
},
{ final: false }
);
progress("bootstrap");
try {
// Foreground and blocking: this stops the old server (us), waits for the
// ports, then starts the new one. It must finish before we record success.
+31
View File
@@ -98,6 +98,16 @@ before(async () => {
"scripted.json",
tool("scripted", { install: { kind: "script", url: "https://example.com/install" } })
);
write(
"redirected.json",
tool("redirected", {
install: {
kind: "script",
url: "https://example.com/install",
redirect: "https://cdn.example.net/install.sh",
},
})
);
write("selfy.json", tool("selfy", { update: { kind: "self", args: ["update", "--yes"] } }));
write("plain.json", tool("plain"));
exe(path.join(shellBin, "npm"));
@@ -342,6 +352,27 @@ describe("jobs", () => {
assert.match(done.log, /larger than 1 MB/);
});
it("follows a redirect off its site only to the catalog's exact redirect URL", async () => {
let landed = "https://cdn.example.net/install.sh?x=1";
globalThis.fetch = async () => ({
ok: true,
status: 200,
url: landed,
arrayBuffer: async () => Buffer.from("echo hi\n"),
});
await jobs.start("redirected", "install");
let done = await settle();
assert.equal(done.status, "failed");
assert.match(done.log, /redirected off example\.com/);
assert.equal(spawned.length, 0);
landed = "https://cdn.example.net/install.sh";
await jobs.start("redirected", "install");
done = await settle();
assert.equal(done.status, "ok");
assert.equal(spawned[0].argv[0], "bash");
});
it("reports the end only after re-detecting, so the first poll sees the installed tool", async () => {
// A slow login-shell read at the end: the job must not look finished before it.
jobs.__setHydrate(() => new Promise((r) => setTimeout(() => r(false), 40)));
+16 -1
View File
@@ -261,6 +261,20 @@ describe("install/update fields and trust tiers", () => {
update: { kind: "script" },
})
);
write(
tb,
"redir.json",
base("redir", {
install: { kind: "script", url: "https://example.com/i", redirect: "https://cdn.io/i.sh" },
})
);
write(
tb,
"badredir.json",
base("badredir", {
install: { kind: "script", url: "https://example.com/i", redirect: "http://cdn.io/i" },
})
);
write(tb, "badpkg.json", base("badpkg", { install: { kind: "npm", package: "Bad Pkg;rm" } }));
write(
tb,
@@ -302,7 +316,8 @@ describe("install/update fields and trust tiers", () => {
url: "https://example.com/install",
});
assert.deepEqual(aiTools.get("claude").update, { kind: "self", args: ["update"] });
for (const id of ["badpkg", "httpurl", "offhost", "npmnoinst", "selfbad"]) {
assert.equal(aiTools.get("redir").install.redirect, "https://cdn.io/i.sh");
for (const id of ["badpkg", "httpurl", "offhost", "badredir", "npmnoinst", "selfbad"]) {
assert.equal(aiTools.has(id), false, id);
}
});
+137 -1
View File
@@ -9,7 +9,9 @@
* file, a folder, a zip, several skills at once — name and frontmatter set,
* junk skipped, nothing stored when one fails) or written in the dialog, a
* file uploaded, sharing refused in local mode, visibility for a server-mode
* developer, the admin's installs switch, and same-origin writes.
* developer, the admin's installs switch, same-origin writes, Claude Docs
* docs captured at their birth, documents captured at a session's stop, and
* unowned rows claimed, declined and assigned.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -120,6 +122,42 @@ describe("pages", () => {
assert.equal(artifacts.captureFromHook("s1", { tool_response: "no link" }), null);
});
it("records a Claude Docs doc at its birth, never on a later batch", () => {
const tool = "mcp__claude_ai_Claude_Docs__batch";
assert.equal(artifacts.isPageTool(tool), true);
assert.equal(artifacts.isPageTool("mcp__claude_ai_Claude_Docs__update"), false);
assert.equal(artifacts.isPageTool("Artifact"), true);
const row = artifacts.captureFromHook("s1", {
tool_name: tool,
tool_input: { container: { kind: "project", create: { name: "Q3 plan" } }, batch: [] },
tool_response: JSON.stringify({ frame: { url: "https://claude.ai/artifact/DocAbc123" } }),
});
assert.equal(row.url, "https://claude.ai/artifact/DocAbc123");
assert.equal(row.title, "Q3 plan");
const edit = artifacts.captureFromHook("s1", {
tool_name: tool,
tool_input: { container: { kind: "project", id: "x" }, batch: [] },
tool_response: JSON.stringify({ frame: { url: "https://claude.ai/artifact/DocOther9" } }),
});
assert.equal(edit, null);
// The doc's own link wins over one its content cites earlier in the ack.
const cited = artifacts.captureFromHook("s1", {
tool_name: tool,
tool_input: { container: { kind: "project", create: { name: "Notes" } }, batch: [] },
tool_response: [
{
type: "text",
text:
JSON.stringify({
acks: [{ xml: "see https://claude.ai/artifact/CitedOne1" }],
frame: { artifactUrl: "https://claude.ai/artifact/OwnDoc22" },
}) + "\n\nThe doc's viewer is at https://claude.ai/artifact/OwnDoc22 — open it.",
},
],
});
assert.equal(cited.url, "https://claude.ai/artifact/OwnDoc22");
});
it("gives a page from a session no workspace owns to the member who sent the hook", () => {
const uid = db
.prepare("INSERT INTO users (username, password_hash) VALUES ('ann', 'x')")
@@ -711,3 +749,101 @@ describe("search, frontmatter and marketplace limits", () => {
assert.ok(pulled < 30, `read ${pulled} chunks`);
});
});
describe("outputs captured at a session's stop", () => {
it("snapshots new documents once, refreshes a changed one, ignores the rest", async () => {
const past = new Date(Date.now() - 3600 * 1000);
fs.utimesSync(path.join(ws, "out", "report.html"), past, past); // older than the session
const deck = path.join(ws, "out", "deck.pptx");
fs.writeFileSync(deck, "PK-deck-v1");
fs.writeFileSync(path.join(ws, "notes.md"), "not an output");
const soon = new Date(Date.now() + 1000); // even a future mtime is captured once
fs.utimesSync(deck, soon, soon);
const first = await artifacts.captureOutputs("s1");
assert.deepEqual(
first.map((r) => r.source),
["out/deck.pptx"]
);
assert.equal(first[0].auto, 1);
assert.equal(first[0].lane_id, lane.id);
assert.equal((await artifacts.captureOutputs("s1")).length, 0, "unchanged: not again");
fs.writeFileSync(deck, "PK-deck-v2");
const later = new Date(Date.now() + 60 * 1000);
fs.utimesSync(deck, later, later);
const again = await artifacts.captureOutputs("s1");
assert.equal(again.length, 1);
assert.equal(again[0].id, first[0].id, "same row, new snapshot");
assert.notEqual(again[0].snapshot, first[0].snapshot);
assert.equal(
artifacts.list(null, { kind: "file" }).find((i) => i.id === first[0].id).auto,
true
);
assert.deepEqual(await artifacts.captureOutputs("no-such-session"), []);
});
});
describe("unowned rows", () => {
it("are suggested, claimed, declined and assigned only by the right people", () => {
const addUser = (name, role) =>
Number(
db
.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', ?)")
.run(name, role).lastInsertRowid
);
const bobId = addUser("bob-u", "developer");
const amyId = addUser("amy-u", "developer");
const adminId = addUser("root-u", "admin");
const bob = { method: "POST", user: { id: bobId, role: "developer", username: "bob-u" } };
const amy = { method: "POST", user: { id: amyId, role: "developer", username: "amy-u" } };
const admin = { method: "POST", user: { id: adminId, role: "admin", username: "root-u" } };
db.prepare("UPDATE lanes SET owner_id = ? WHERE id = ?").run(bobId, lane.id);
process.env.AGENTHUB_MODE = "server";
try {
// A host session with no workspace, run inside bob's workspace folder.
const row = artifacts.captureFromHook(
"loose-host",
{
tool_input: { action: "publish" },
tool_response: "https://claude.ai/artifact/un1",
cwd: ws,
},
{ kind: "host" }
);
assert.equal(row.owner_id, null);
assert.equal(row.suggested_owner_id, bobId);
assert.ok(
artifacts.list(bob, { scope: "unowned" }).some((i) => i.id === row.id && i.suggestedForMe)
);
assert.ok(
artifacts.list(admin, { scope: "unowned" }).some((i) => i.id === row.id && i.unowned)
);
assert.equal(artifacts.list(amy, { scope: "unowned" }).length, 0);
assert.equal(artifacts.canSeeId(amy, row.id), false);
assert.throws(() => artifacts.claim(amy, row.id), { code: "ENOTFOUND" });
const claimed = artifacts.claim(bob, row.id);
assert.equal(claimed.ownerId, bobId);
assert.throws(() => artifacts.claim(bob, row.id), { code: "EOWNED" });
const other = artifacts.captureFromHook(
"loose-host-2",
{
tool_input: { action: "publish" },
tool_response: "https://claude.ai/artifact/un2",
cwd: ws,
},
{ kind: "host" }
);
assert.deepEqual(artifacts.decline(bob, other.id), { id: other.id, declined: true });
assert.equal(artifacts.canSeeId(bob, other.id), false, "declined: no longer offered");
assert.throws(() => artifacts.assign(bob, other.id, bobId), { code: "ENOTFOUND" });
assert.throws(() => artifacts.assign(admin, other.id, 999999), { code: "EBADFIELD" });
assert.equal(artifacts.assign(admin, other.id, amyId).ownerId, amyId);
} finally {
delete process.env.AGENTHUB_MODE;
}
assert.equal(artifacts.list(null, { scope: "unowned" }).length, 0, "local mode has no owners");
});
});
+19
View File
@@ -143,6 +143,25 @@ describe("POST /api/lanes/clone", () => {
assert.equal(again.body.error.code, "EEXISTS");
});
it("re-attaches a workspace whose folder was deleted instead of failing UNIQUE", async () => {
const first = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "gone",
});
assert.equal(first.status, 201, JSON.stringify(first.body));
fs.rmSync(path.join(WORK, "gone"), { recursive: true, force: true });
const again = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "gone",
});
assert.equal(again.status, 200, JSON.stringify(again.body));
assert.equal(again.body.created, false);
assert.equal(again.body.lane.id, first.body.lane.id);
assert.ok(fs.existsSync(path.join(WORK, "gone", "README.md")));
});
it("clones over ssh with the user's key, kept in the clone's config", async () => {
const made = await request("POST", "/api/me/git/ssh-key", {});
assert.equal(made.status, 201);
+89
View File
@@ -2092,6 +2092,95 @@ describe("an extra folder's own repo (?source=)", () => {
});
});
describe("repos inside a parent workspace folder (?repo=)", () => {
const { execFileSync } = require("child_process");
const gitEnv = Object.fromEntries(
Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_"))
);
const gitIn = (dir, ...args) =>
execFileSync("git", ["-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", ...args], {
encoding: "utf8",
env: gitEnv,
});
let parent, laneId, otherLaneId;
before(async () => {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "lane-nested-")));
parent = path.join(root, "shop");
for (const rel of ["api", "web", "group/admin"]) {
const dir = path.join(parent, rel);
fs.mkdirSync(dir, { recursive: true });
gitIn(dir, "init", "-q", "-b", "main");
fs.writeFileSync(path.join(dir, "a.txt"), "a\n");
gitIn(dir, "add", ".");
gitIn(dir, "commit", "-qm", "init");
}
// Not repos: a plain folder, a dot folder and node_modules holding one,
// and a symlink to a repo (never followed).
fs.mkdirSync(path.join(parent, "docs"));
fs.mkdirSync(path.join(parent, ".cache/x/.git"), { recursive: true });
fs.mkdirSync(path.join(parent, "node_modules/pkg/.git"), { recursive: true });
fs.symlinkSync(path.join(parent, "api"), path.join(parent, "api-link"));
// A repo found inside a repo is not walked into.
fs.mkdirSync(path.join(parent, "web/inner/.git"), { recursive: true });
laneId = (await request("POST", "/api/lanes", { cwd: parent })).body.lane.id;
otherLaneId = (await request("POST", "/api/lanes", { cwd: path.join(root, "other") })).body.lane
.id;
});
it("lists the repos below the workspace folder", async () => {
const r = await request("GET", `/api/lanes/${laneId}/repos`);
assert.equal(r.status, 200);
assert.equal(r.body.root, false);
assert.deepEqual(
r.body.items.map((i) => i.path),
["api", "group/admin", "web"]
);
});
it("reads and writes git in the named repo only", async () => {
fs.writeFileSync(path.join(parent, "group/admin/new.txt"), "x\n");
const changes = await request("GET", `/api/lanes/${laneId}/git/changes?repo=group/admin`);
assert.ok(changes.body.files.some((f) => f.path === "new.txt"));
const facts = await request("GET", `/api/lanes/${laneId}/git?repo=api`);
assert.equal(facts.body.available, true);
assert.equal(facts.body.base, "main");
const ok = await request("POST", `/api/lanes/${laneId}/git/stage?repo=group/admin`, {
paths: ["new.txt"],
});
assert.equal(ok.status, 200);
assert.match(
gitIn(path.join(parent, "group/admin"), "diff", "--cached", "--name-only"),
/new\.txt/
);
});
it("answers ENOREPO for any path the walk did not find", async () => {
for (const rel of ["docs", "..", "../other", "/etc", "api-link", ".cache/x", "web/inner", ""]) {
const r = await request(
"GET",
`/api/lanes/${laneId}/git/changes?repo=${encodeURIComponent(rel)}`
);
assert.equal(r.status, 404, rel);
assert.equal(r.body.error.code, "ENOREPO", rel);
}
const r = await request("GET", `/api/lanes/${otherLaneId}/git/changes?repo=api`);
assert.equal(r.status, 404);
assert.equal(r.body.error.code, "ENOREPO");
});
it("finds a repo cloned since the last read", async () => {
require("../lib/nested-repos").clearNestedRepoCache();
await request("GET", `/api/lanes/${laneId}/repos`);
await new Promise((r) => setTimeout(r, 2100));
const dir = path.join(parent, "late");
fs.mkdirSync(dir);
gitIn(dir, "init", "-q", "-b", "main");
const r = await request("GET", `/api/lanes/${laneId}/git?repo=late`);
assert.equal(r.status, 200);
});
});
// The server, not a stale client list, picks the tab and refuses a second pane
// on a session one already holds.
describe("start: slot auto and a session already live", () => {
+18
View File
@@ -74,6 +74,24 @@ describe("local mode: one account", () => {
}
});
it("keeps the sidebar's workspace groups, up to 64 KB", async () => {
const groups = JSON.stringify({ groups: [{ id: "g1", name: "Web" }], of: { 1: "g1" } });
const pad = "x".repeat(20 * 1024);
let r = await call("PUT", "/api/me/prefs", {
body: { prefs: { "agenthub.workspace.groups": groups + pad } },
});
assert.equal(r.status, 200);
assert.equal(r.body.prefs["agenthub.workspace.groups"], groups + pad);
r = await call("PUT", "/api/me/prefs", {
body: { prefs: { "agenthub.workspace.groups": "x".repeat(65 * 1024) } },
});
assert.equal(r.status, 400);
r = await call("PUT", "/api/me/prefs", {
body: { prefs: { "agenthub.workspace.groups": null } },
});
assert.equal(r.body.prefs["agenthub.workspace.groups"], undefined);
});
it("keeps the account's own agent instructions, capped, blank clears", async () => {
let r = await call("GET", "/api/me/instructions");
assert.deepEqual(r.body, { instructions: "", max: 16000, tools: ["claude", "codex"] });
+57 -1
View File
@@ -19,7 +19,12 @@ const os = require("os");
const path = require("path");
const { execFileSync } = require("child_process");
const { readLastPluginUpdate, schedulePluginUpdate, runtimeDir } = require("../lib/plugin-update");
const {
readLastPluginUpdate,
readPluginUpdateProgress,
schedulePluginUpdate,
runtimeDir,
} = require("../lib/plugin-update");
const HELPER = path.join(__dirname, "..", "..", "scripts", "plugin-update-helper.js");
@@ -84,6 +89,43 @@ describe("readLastPluginUpdate", () => {
});
});
describe("readPluginUpdateProgress", () => {
const write = (name, body) => {
const rt = path.join(tmpDir, name);
fs.mkdirSync(rt, { recursive: true });
if (body !== undefined) fs.writeFileSync(path.join(rt, "plugin-update-progress.json"), body);
return rt;
};
it("is null with no file and for malformed JSON", () => {
assert.equal(readPluginUpdateProgress(write("prog-none")), null);
assert.equal(readPluginUpdateProgress(write("prog-bad", "{nope")), null);
});
it("reads the running step", () => {
const started_at = new Date().toISOString();
const rt = write(
"prog-ok",
JSON.stringify({ step: "plugin_update", index: 2, total: 3, started_at })
);
assert.deepEqual(readPluginUpdateProgress(rt), {
step: "plugin_update",
index: 2,
total: 3,
started_at,
});
});
it("ignores a file left by a helper that died long ago", () => {
const old = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const rt = write(
"prog-stale",
JSON.stringify({ step: "bootstrap", index: 3, total: 3, started_at: old })
);
assert.equal(readPluginUpdateProgress(rt), null);
});
});
describe("schedulePluginUpdate", () => {
it("clears a stale verdict before spawning, so an old success can't be read as this run's result", () => {
// Redirect the runtime dir into the temp tree — this must not touch the
@@ -152,6 +194,20 @@ describe("plugin-update-helper name guard", () => {
assert.equal(v.step, "validate");
});
it("removes a progress record with its final verdict", () => {
const rt = path.join(tmpDir, "guard-progress");
fs.mkdirSync(rt, { recursive: true });
fs.writeFileSync(
path.join(rt, "plugin-update-progress.json"),
JSON.stringify({ step: "plugin_update", started_at: new Date().toISOString() })
);
execFileSync(process.execPath, [HELPER, "../evil", "agenthub", "/nonexistent/root", rt], {
stdio: "ignore",
timeout: 30_000,
});
assert.equal(fs.existsSync(path.join(rt, "plugin-update-progress.json")), false);
});
it("stamps every verdict with a finish time", () => {
// The failure mode this file exists to prevent is a helper that dies
// silently: the tab would poll a server that never returns and say nothing.
+7 -3
View File
@@ -81,9 +81,13 @@ const RULES = [
// a Gitea /raw/branch/<ref>/ URL instead.
re: /raw\.githubusercontent\.com\/[\w-]+\/[\w.-]+/g,
fix: "use a same-origin path, or a Gitea /raw/branch/<ref>/ URL",
// A third-party project that really is on GitHub: the public model price
// list server/lib/pricing-sync.js reads.
skip: [/raw\.githubusercontent\.com\/BerriAI\/litellm$/],
// Third-party projects that really are on GitHub: the public model price
// list server/lib/pricing-sync.js reads, and the install script omp.sh
// redirects to (server/data/ai-tools/omp.json `install.redirect`).
skip: [
/raw\.githubusercontent\.com\/BerriAI\/litellm$/,
/raw\.githubusercontent\.com\/can1357\/oh-my-pi$/,
],
},
{
name: "GitHub Pages host for a Smartgift site",
+18 -1
View File
@@ -57,10 +57,22 @@ before(async () => {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd) VALUES ('sx', 'host work', 'active', '/w/host')"
).run();
db.prepare(
`INSERT INTO token_usage (session_id, model, output_tokens)
VALUES ('sm', 'claude-sonnet-4-5-20250929', 1000000)`
).run();
db.prepare(
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
VALUES ('sm', ?, 'claude-sonnet-4-5-20250929', 1000000)`
).run(new Date().toISOString().slice(0, 13));
// A tool with no hourly rows (omp, Codex…): its total counts from its start.
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, tool) VALUES ('so', 'mia omp', 'active', '/w/mia', ?, 'omp')"
).run(lane);
db.prepare(
`INSERT INTO token_usage (session_id, model, output_tokens)
VALUES ('so', 'claude-sonnet-4-5-20250929', 500000)`
).run();
});
after(() => {
@@ -76,8 +88,13 @@ describe("staff views", () => {
assert.equal(r.status, 200);
const mia = r.body.users.find((u) => u.username === "mia");
assert.equal(mia.workspaces, 1);
assert.equal(mia.sessions, 1);
assert.equal(mia.sessions, 2);
assert.equal(mia.today.tokens, 1500000, "a session with no hourly rows counts too");
assert.ok(mia.last7d.cost > 0 && mia.last30d.cost >= mia.last7d.cost);
// This hour's spend is today's in every timezone.
assert.equal(mia.today.cost, mia.last7d.cost);
const east = await call("/api/users?tz_offset=-840", admin);
assert.equal(east.body.users.find((u) => u.username === "mia").today.tokens, mia.today.tokens);
assert.ok(!JSON.stringify(r.body).includes("scrypt"), "no password hash");
assert.equal((await call("/api/users", await login("ana", "analyst-pass-1"))).status, 200);
const member = await call("/api/users", await login("mia", "member-pass-1"));
+184
View File
@@ -0,0 +1,184 @@
/**
* @file Tests for counting every token a Claude session spent: the hook
* writer adds the session's sub-agent transcripts (`lib/subagent-usage.js`)
* to its main one — total and per hour, a sub-agent on the session's own
* model included, and a sub-agent that grew alone is picked up — and the
* importer raises a session it first saw with an empty transcript once the
* transcript has grown (`importSession`).
* @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("path");
const fs = require("fs");
const os = require("os");
const http = require("http");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-subusage-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, "home");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
const { createApp, startServer } = require("../index");
const dbModule = require("../db");
const { db } = dbModule;
const importHistory = require("../../scripts/import-history");
const { subagentUsage } = require("../lib/subagent-usage");
let server;
let BASE;
function post(body) {
return new Promise((resolve, reject) => {
const url = new URL("/api/hooks/event", BASE);
const payload = JSON.stringify(body);
const r = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
},
(res) => {
res.resume();
res.on("end", () => resolve(res.statusCode));
}
);
r.on("error", reject);
r.write(payload);
r.end();
});
}
const line = (o) => JSON.stringify(o) + "\n";
const assistant = (id, inTok, ts, model = "claude-sonnet-5") => ({
type: "assistant",
timestamp: ts,
message: {
id,
model,
content: [{ type: "text", text: "ok" }],
usage: { input_tokens: inTok, output_tokens: 1 },
},
});
const total = (sid) =>
db
.prepare(
"SELECT model, SUM(input_tokens + baseline_input) AS input FROM token_usage WHERE session_id = ? GROUP BY model ORDER BY model"
)
.all(sid)
.map((r) => ({ ...r }));
const hours = (sid) =>
db
.prepare(
"SELECT hour, model, SUM(input_tokens) AS input FROM token_usage_hourly WHERE session_id = ? GROUP BY hour, model ORDER BY hour, model"
)
.all(sid)
.map((r) => ({ ...r }));
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("sub-agent usage", () => {
it("counts a session's sub-agents, own model included, in its total and its hours", async () => {
const sid = "cdcdcdcd-1111-4111-8111-cdcdcdcdcdcd";
const cwd = path.join(TMP, "work");
fs.mkdirSync(cwd, { recursive: true });
const tpath = path.join(TMP, `${sid}.jsonl`);
fs.writeFileSync(tpath, line(assistant("m1", 100, "2026-10-22T01:00:00.000Z")));
const subDir = path.join(TMP, sid, "subagents");
fs.mkdirSync(subDir, { recursive: true });
const sub1 = path.join(subDir, "agent-a1.jsonl");
fs.writeFileSync(sub1, line(assistant("s1", 40, "2026-10-22T02:00:00.000Z")));
fs.writeFileSync(
path.join(subDir, "agent-a2.jsonl"),
line(assistant("s2", 7, "2026-10-22T02:30:00.000Z", "claude-haiku-5-5"))
);
// A Workflow agent is booked by workflow-ingest under its own tier: not here.
fs.mkdirSync(path.join(subDir, "workflows", "run1"), { recursive: true });
fs.writeFileSync(
path.join(subDir, "workflows", "run1", "agent-w.jsonl"),
line(assistant("w1", 1000, "2026-10-22T02:00:00.000Z"))
);
const data = { session_id: sid, cwd, transcript_path: tpath };
assert.equal(await post({ hook_type: "PostToolUse", data }), 200);
assert.deepEqual(total(sid), [
{ model: "claude-haiku-5-5", input: 7 },
{ model: "claude-sonnet-5", input: 140 },
]);
assert.deepEqual(hours(sid), [
{ hour: "2026-10-22T01", model: "claude-sonnet-5", input: 100 },
{ hour: "2026-10-22T02", model: "claude-haiku-5-5", input: 7 },
{ hour: "2026-10-22T02", model: "claude-sonnet-5", input: 40 },
]);
// Only the sub-agent grew: the next hook still counts it, once.
fs.appendFileSync(sub1, line(assistant("s3", 10, "2026-10-22T03:00:00.000Z")));
assert.equal(await post({ hook_type: "PostToolUse", data: { ...data, tool_name: "x" } }), 200);
assert.equal(await post({ hook_type: "PostToolUse", data: { ...data, tool_name: "y" } }), 200);
assert.deepEqual(total(sid), [
{ model: "claude-haiku-5-5", input: 7 },
{ model: "claude-sonnet-5", input: 150 },
]);
assert.equal(hours(sid).find((h) => h.hour === "2026-10-22T03").input, 10);
// The importer's full re-read agrees and changes nothing (high-water).
await importHistory.reconcileTokens(dbModule, { exact: false });
assert.deepEqual(total(sid), [
{ model: "claude-haiku-5-5", input: 7 },
{ model: "claude-sonnet-5", input: 150 },
]);
});
it("is null for a session with no sub-agent transcript", () => {
assert.equal(subagentUsage(path.join(TMP, "nope.jsonl")), null);
assert.equal(subagentUsage(null), null);
});
it("raises an imported session first seen before its transcript had any usage", async () => {
const sid = "efefefef-2222-4222-8222-efefefefefef";
const proj = path.join(TMP, "projects", "-tmp-eval");
fs.mkdirSync(proj, { recursive: true });
const tpath = path.join(proj, `${sid}.jsonl`);
const user = {
type: "user",
sessionId: sid,
cwd: "/tmp/eval",
timestamp: "2026-10-23T03:00:00.000Z",
message: { role: "user", content: "fix the bug" },
};
fs.writeFileSync(tpath, line(user));
const parse = async () => {
const s = await importHistory.parseSessionFile(tpath);
s.parsedSubagents = [];
return s;
};
importHistory.importSession(dbModule, await parse());
assert.deepEqual(total(sid), []);
fs.appendFileSync(
tpath,
line({ ...assistant("e1", 500, "2026-10-23T03:01:00.000Z"), sessionId: sid })
);
const again = importHistory.importSession(dbModule, await parse());
assert.equal(again.backfilled, true);
assert.deepEqual(total(sid), [{ model: "claude-sonnet-5", input: 500 }]);
// Nothing new: no backfill reported, no change.
assert.equal(importHistory.importSession(dbModule, await parse()).backfilled, undefined);
assert.deepEqual(total(sid), [{ model: "claude-sonnet-5", input: 500 }]);
});
});
+2 -1
View File
@@ -26,7 +26,8 @@
"faviconDomain": "omp.sh",
"install": {
"kind": "script",
"url": "https://omp.sh/install"
"url": "https://omp.sh/install",
"redirect": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/scripts/install.sh"
},
"update": {
"kind": "script"
+14
View File
@@ -960,6 +960,20 @@ try {
} catch {
db.prepare("ALTER TABLE artifacts ADD COLUMN in_library INTEGER NOT NULL DEFAULT 1").run();
}
// `auto = 1`: a document file a session wrote, captured when it stopped
// (`captureOutputs`), not pinned by hand. `suggested_owner_id`: for a row with
// no owner, the user whose folder the session ran in — they may claim it or
// say it is not theirs (`claim`/`decline`); an admin assigns any (`assign`).
for (const [col, ddl] of [
["auto", "auto INTEGER NOT NULL DEFAULT 0"],
["suggested_owner_id", "suggested_owner_id INTEGER"],
]) {
try {
db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get();
} catch {
db.prepare(`ALTER TABLE artifacts ADD COLUMN ${ddl}`).run();
}
}
// One row per install of an artifact (server/lib/artifacts.js `install`):
// who, when, and the folders it was written to — `artifacts.installs` only
+25
View File
@@ -690,6 +690,31 @@ function startBackgroundServices() {
}
}
// One-time (user_version 5) raise of every session's totals to its whole
// transcript plus its sub-agents' (raise only: `exact: false`). Before
// v2.8.23 a live session never counted a sub-agent on its own model, and a
// session imported the moment its file appeared (a `claude -p` run with no
// hooks) kept the usage of that moment, often none — both now counted as
// they happen (server/lib/subagent-usage.js, importSession). Waits for
// marker 4; written only on success, so a crash retries on the next start.
{
const dbModule = require("./db");
if (dbModule.db.pragma("user_version", { simple: true }) === 4) {
const t = setTimeout(() => {
require("../scripts/import-history")
.reconcileTokens(dbModule, { exact: false })
.then((c) => {
console.log(
`Token catch-up: re-read ${c.sessionsTouched} session(s), ${c.modelsWritten} model row(s)`
);
dbModule.db.pragma("user_version = 5");
})
.catch((err) => console.warn("token catch-up failed:", err?.message || err));
}, 15_000);
if (t.unref) t.unref();
}
}
// Per-hour tokens for sessions recorded before `token_usage_hourly`, so the
// daily cost split covers them too. Picks only sessions with no hourly rows,
// so a later start re-reads nothing already done; non-blocking.
+12 -6
View File
@@ -63,12 +63,14 @@ async function stepsFor(id, action) {
if (d.installed) throw makeErr("EINSTALLED", `${tool.label} is already installed`);
return tool.install.kind === "npm"
? [{ argv: npmArgv(tool.install.package, await versions.installPrefixArgs()) }]
: [{ script: tool.install.url }];
: [{ script: tool.install.url, redirect: tool.install.redirect }];
}
if (!tool.update) throw makeErr("ENOUPDATE", `${tool.label} has no update command`);
if (!d.installed) throw makeErr("ENOTINSTALLED", `${tool.label} is not installed`);
if (tool.update.kind === "self") return [{ argv: [d.path, ...tool.update.args] }];
if (tool.update.kind === "script") return [{ script: tool.install.url }];
if (tool.update.kind === "script") {
return [{ script: tool.install.url, redirect: tool.install.redirect }];
}
const by = await versions.managedBy(id);
if (!by) {
throw makeErr(
@@ -85,12 +87,16 @@ function append(j, text) {
if (j.log.length > LOG_CAP) j.log = j.log.slice(-LOG_CAP);
}
/** Download a vendor script to a 0600 temp file; the final URL must stay on its site. */
async function download(url) {
/**
* Download a vendor script to a 0600 temp file; the final URL must stay on its
* site, or be exactly the catalog's `redirect`.
*/
async function download(url, redirect) {
const res = await fetch(url, { signal: AbortSignal.timeout(15_000), redirect: "follow" });
const final = new URL(res.url || url);
const site = aiTools.siteOf(new URL(url).hostname);
if (final.protocol !== "https:" || aiTools.siteOf(final.hostname) !== site) {
const onSite = final.protocol === "https:" && aiTools.siteOf(final.hostname) === site;
if (!onSite && final.href !== redirect) {
throw new Error(`script redirected off ${site}: ${final.href}`);
}
if (!res.ok) throw new Error(`script download failed: HTTP ${res.status}`);
@@ -169,7 +175,7 @@ async function runSteps(j, steps, onStepOk) {
let tmp = null;
if (step.script) {
append(j, `$ download ${step.script}\n`);
tmp = await download(step.script);
tmp = await download(step.script, step.redirect);
argv = ["bash", tmp];
}
try {
+17 -4
View File
@@ -44,18 +44,24 @@ const isStrArray = (v, re) =>
// wrong for co.uk-style hosts (none in the catalog); swap in a PSL if one comes.
const siteOf = (host) => host.toLowerCase().split(".").slice(-2).join(".");
/** The script URL, when it is https on the homepage's site; else null. */
function scriptUrl(url, homepageUrl) {
/** A plain https URL (no userinfo, no port), normalised; else null. */
function httpsUrl(url) {
try {
const u = new URL(url);
const home = new URL(homepageUrl);
if (u.protocol !== "https:" || u.username || u.password || u.port) return null;
return siteOf(u.hostname) === siteOf(home.hostname) ? u.href : null;
return u;
} catch {
return null;
}
}
/** The script URL, when it is https on the homepage's site; else null. */
function scriptUrl(url, homepageUrl) {
const u = httpsUrl(url);
const home = httpsUrl(homepageUrl);
return u && home && siteOf(u.hostname) === siteOf(home.hostname) ? u.href : null;
}
/** `{install, update}` normalised, or null when either is malformed. */
function installFields(doc) {
let install = null;
@@ -67,6 +73,13 @@ function installFields(doc) {
const url = scriptUrl(i.url, doc.homepageUrl);
if (!url) return null;
install = { kind: "script", url };
// The ONE exact URL the download may redirect to off the homepage's site
// (omp.sh/install → raw.githubusercontent.com); never a site wildcard.
if (i.redirect !== undefined) {
const to = typeof i.redirect === "string" && httpsUrl(i.redirect);
if (!to) return null;
install.redirect = to.href;
}
} else return null;
}
let update = null;
+281 -18
View File
@@ -6,8 +6,12 @@
* Four kinds:
* - `page` a claude.ai Artifact a session published: only a link, accepted
* only as `https://claude.ai/…`. Captured from the `Artifact`
* tool's PostToolUse hook (`captureFromHook`) or pasted by hand.
* - `file` a file an agent wrote in a workspace, pinned by the user.
* tool's PostToolUse hook (`captureFromHook`; a Claude Docs doc's
* birth too) or pasted by hand. In server mode a row nobody owns
* waits for its suggested user or an admin (`claim`/`decline`/`assign`).
* - `file` a file an agent wrote in a workspace, pinned by the user — or a
* document (pptx/docx/xlsx/pdf/html) a session created, captured
* when it stops (`captureOutputs`, `auto`).
* - `skill` a Claude Code skill folder (the user's own, or a workspace's
* `.claude/skills/<name>`).
* - `plugin` a plugin by `name@marketplace` and its marketplace source; no
@@ -85,14 +89,21 @@ const installsOffFlag = () => path.join(root(), "installs-off");
function visibleWhere(req) {
const u = req?.user;
if (!u || u.role === "admin" || u.role === "analyst") return { where: "1", params: [] };
return { where: "(a.owner_id = ? OR a.shared = 1)", params: [u.id] };
return {
where: "(a.owner_id = ? OR a.shared = 1 OR (a.owner_id IS NULL AND a.suggested_owner_id = ?))",
params: [u.id, u.id],
};
}
const canSee = (req, row) => {
const u = req?.user;
if (!row) return false;
if (!u || u.role === "admin" || u.role === "analyst") return true;
return row.owner_id === u.id || row.shared === 1;
return (
row.owner_id === u.id ||
row.shared === 1 ||
(row.owner_id == null && row.suggested_owner_id === u.id)
);
};
/** Only the owner changes a row — or an admin, or anyone in local mode. */
@@ -101,7 +112,10 @@ const canChange = (req, row) =>
/** For scope.js's PATH_RULES: may this request name artifact `id`? */
function canSeeId(req, id) {
return canSee(req, sql("SELECT owner_id, shared FROM artifacts WHERE id = ?").get(Number(id)));
return canSee(
req,
sql("SELECT owner_id, shared, suggested_owner_id FROM artifacts WHERE id = ?").get(Number(id))
);
}
function load(req, id) {
@@ -144,6 +158,10 @@ function view(row, req, owners = null) {
sharedAt: row.shared_at,
inLibrary: row.in_library !== 0,
installs: row.installs,
auto: row.auto === 1,
// Server mode only: a row nobody owns waits for an admin or its suggested user.
unowned: isServer() && row.owner_id == null,
suggestedForMe: !!req?.user && row.owner_id == null && row.suggested_owner_id === req.user.id,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
@@ -152,7 +170,9 @@ function view(row, req, owners = null) {
/**
* The rows this request may see. `scope`: `mine` (own library rows plus the
* shared skills/plugins of others the caller has installed; every row in
* local mode), `hub` (shared rows, server mode), anything else = all visible.
* local mode), `hub` (shared rows, server mode), `unowned` (server mode: rows
* nobody owns — every one for an admin, those suggested to the caller for
* anyone else), anything else = all visible.
* Each row also carries `installed` (`installedCheck`).
*/
function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } = {}) {
@@ -168,6 +188,13 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
params.push(req.user.id, req.user.id);
} else if (scope === "hub") {
where.push("a.shared = 1");
} else if (scope === "unowned") {
if (!isServer() || !req?.user) return [];
where.push("a.owner_id IS NULL");
if (req.user.role !== "admin") {
where.push("a.suggested_owner_id = ?");
params.push(req.user.id);
}
}
if (kind) {
if (!KINDS.includes(kind)) throw fail("EBADFIELD", `kind: one of ${KINDS.join(", ")}`);
@@ -297,23 +324,90 @@ function addPage(req, { url, title, description, laneId, sessionId }) {
* connection came from (`hook-auth.js` `hookPeer`, by uid — never the body).
* The host's account is shared by every admin, so it names nobody.
*/
/**
* Whose a row with no owner probably is, from the folder its session ran in:
* the owner of the workspace holding that folder, else (S3) the member whose
* home it is. Only a suggestion — that user confirms (`claim`). Server mode only.
*/
function suggestOwner(cwd) {
if (!isServer() || typeof cwd !== "string" || !path.isAbsolute(cwd)) return null;
const lane = require("./lanes").resolveLaneByCwd(cwd);
if (lane?.owner_id != null) return lane.owner_id;
try {
const acct = require("./run-as").ownerForRead(cwd);
if (!acct?.name) return null;
const row = sql("SELECT id FROM users WHERE username = ? AND disabled = 0").get(acct.name);
return row ? row.id : null;
} catch {
return null;
}
}
function peerOwner(peer) {
if (peer?.kind !== "member" || !peer.name) return null;
const row = sql("SELECT id FROM users WHERE username = ? AND disabled = 0").get(peer.name);
return row ? row.id : null;
}
/**
* A claude.ai connector's `batch` tool (Claude Docs today, `mcp__claude_ai_Claude_Docs__batch`):
* the one that births a doc. Slides, dashboards and designs are made through the
* `Artifact` tool (`type_url`) and are captured as its publish.
*/
const DOCS_BATCH_RE = /^mcp__[A-Za-z0-9_-]*claude_ai_[A-Za-z0-9_-]*__batch$/i;
/** Whether a PostToolUse of this tool may have published a claude.ai page. */
function isPageTool(toolName) {
return toolName === "Artifact" || DOCS_BATCH_RE.test(String(toolName || ""));
}
/** The `container.create` of a Claude Docs `batch` — set only when it births a doc. */
function docsBirth(input) {
let container = input.container;
if (typeof container === "string") {
try {
container = JSON.parse(container);
} catch {
return null;
}
}
const create = container?.create;
return create && typeof create === "object" ? create : null;
}
/**
* `frame.artifactUrl` (or `frame.url`) of a Claude Docs ack. The ack is JSON with a prose
* trailer appended in the same text block, so it is matched, not parsed.
*/
const FRAME_URL_RE = /"frame":\{[^{}]*?"(?:artifactUrl|url)":"(https:\/\/claude\.ai\/[^"]+)"/;
function docsFrameUrl(response) {
const blocks = Array.isArray(response) ? response : [response];
for (const b of blocks) {
const raw = typeof b === "string" ? b : b?.text;
const m = typeof raw === "string" ? FRAME_URL_RE.exec(raw) : null;
if (m && PAGE_URL_RE.test(m[1])) return m[1];
}
return null;
}
/**
* The `Artifact` tool's PostToolUse hook: a publish answers with the page's
* claude.ai URL somewhere in its response. Recorded once per (owner, url),
* owned by the session's workspace owner, else by the member who sent the
* hook (`peerOwner`). Returns the row or null.
* claude.ai URL somewhere in its response. A Claude Docs `batch` that births a
* doc answers with the doc's link the same way (later batches edit it, and are
* skipped). Recorded once per (owner, url), owned by the session's workspace
* owner, else by the member who sent the hook (`peerOwner`). Returns the row
* or null.
*/
function captureFromHook(sessionId, data, peer = null) {
const input = data?.tool_input || {};
const docs = DOCS_BATCH_RE.test(String(data?.tool_name || ""));
const birth = docs ? docsBirth(input) : null;
if (docs && !birth) return null;
if (input.action && input.action !== "publish") return null;
// A doc's own link is its ack's `frame`; the first link in the text may be one the doc's content cites.
const framed = birth ? docsFrameUrl(data?.tool_response) : null;
const text = JSON.stringify(data?.tool_response ?? "");
const m = PAGE_IN_TEXT_RE.exec(text);
const m = framed ? [framed] : PAGE_IN_TEXT_RE.exec(text);
if (!m) return null;
const url = m[0].replace(/[\\"]+$/, "");
if (!PAGE_URL_RE.test(url)) return null;
@@ -327,13 +421,14 @@ function captureFromHook(sessionId, data, peer = null) {
const file = typeof input.file_path === "string" ? input.file_path : "";
return insert({
kind: "page",
title: cleanTitle(input.title, file ? path.basename(file) : "Artifact"),
title: cleanTitle(birth ? birth.name : input.title, file ? path.basename(file) : "Artifact"),
description: cleanDescription(input.description),
owner_id: ownerId,
lane_id: laneId,
session_id: String(sessionId),
source: file ? file.slice(0, 1000) : null,
url,
suggested_owner_id: ownerId == null ? suggestOwner(data?.cwd) : null,
});
}
@@ -389,9 +484,8 @@ function fileCandidates(laneId) {
return out;
}
/** Pin one file of a workspace: a snapshot of it, under the read path's guards. */
async function pinFile(req, { laneId, path: rel, title, description, sessionId }) {
const lane = laneOf(laneId);
/** A new snapshot of one workspace file, read under the read path's guards. */
async function snapshotLaneFile(lane, rel) {
const { openDownload } = require("./lane-file-transfer");
const file = await openDownload(lane.cwd, typeof rel === "string" ? rel : "");
if (file.size > FILE_MAX_BYTES) {
@@ -399,10 +493,9 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
throw fail("ETOOBIG", "a file artifact is at most 25 MB");
}
const snap = newSnapshotDir();
const name = file.name;
try {
await new Promise((resolve, reject) => {
const out = fs.createWriteStream(path.join(snap.dir, name), { mode: 0o600 });
const out = fs.createWriteStream(path.join(snap.dir, file.name), { mode: 0o600 });
file.stream.on("error", reject);
out.on("error", reject);
out.on("finish", resolve);
@@ -412,6 +505,13 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
dropSnapshot(snap.id);
throw err;
}
return { snap, name: file.name, size: file.size };
}
/** Pin one file of a workspace: a snapshot of it, under the read path's guards. */
async function pinFile(req, { laneId, path: rel, title, description, sessionId }) {
const lane = laneOf(laneId);
const { snap, name, size } = await snapshotLaneFile(lane, rel);
const relPosix = require("./git-diff").lanePath(rel);
return view(
insert({
@@ -423,13 +523,121 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
session_id: sessionId ? String(sessionId) : null,
source: relPosix,
snapshot: snap.id,
files: JSON.stringify([{ path: name, size: file.size }]),
size: file.size,
files: JSON.stringify([{ path: name, size }]),
size,
}),
req
);
}
// ── Outputs captured when a session stops ───────────────────────────────────
/** Documents a session makes for people (slides, Word, Excel, PDF, HTML reports). */
const OUTPUT_EXT = /\.(pptx|docx|xlsx|pdf|html?)$/i;
/** Most files one stop captures, so a script that writes hundreds stays bounded. */
const OUTPUT_MAX = 20;
const capturing = new Map();
/**
* The workspace's documents a session created or changed since it started:
* untracked or modified files in a repo (an HTML file only when untracked, so a
* site's own pages are not taken), every file of a plain folder. Paths are
* workspace-relative.
*/
async function outputCandidates(lane, since) {
const gd = require("./git-diff");
let paths;
let tracked = new Set();
if (await gd.inRepo(lane.cwd)) {
const untracked = (await gd.lsFiles(lane.cwd, ["--others", "--exclude-standard"])).all;
const modified = (await gd.lsFiles(lane.cwd, ["--modified"])).all;
tracked = new Set(modified);
paths = [...untracked, ...modified];
} else {
paths = (await gd.walkFolder(lane.cwd)).files;
}
const out = [];
for (const rel of new Set(paths)) {
if (!OUTPUT_EXT.test(rel)) continue;
if (/\.html?$/i.test(rel) && tracked.has(rel)) continue;
let st;
try {
st = await fs.promises.lstat(path.join(lane.cwd, rel));
} catch {
continue;
}
if (!st.isFile() || st.mtimeMs < since || st.size > FILE_MAX_BYTES) continue;
out.push({ rel: gd.lanePath(rel), mtimeMs: st.mtimeMs });
}
return out.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, OUTPUT_MAX);
}
/**
* At a session's Stop: snapshot each document it created or changed into its
* workspace owner's Artifacts, marked `auto`. A file seen again with the mtime
* its snapshot recorded is skipped; a changed one replaces the row's snapshot. A file the user
* pinned by hand is left to them. Returns the rows added or refreshed.
*/
function captureOutputs(sessionId) {
const laneId = sessionLane(sessionId);
if (laneId == null) return Promise.resolve([]);
// One capture per workspace at a time: two Stops in a row must not both insert.
const prev = capturing.get(laneId) || Promise.resolve();
const run = prev.then(() => captureOutputsNow(laneId, sessionId)).catch(() => []);
capturing.set(laneId, run);
run.finally(() => {
if (capturing.get(laneId) === run) capturing.delete(laneId);
});
return run;
}
async function captureOutputsNow(laneId, sessionId) {
const lane = require("./lanes").getLane(Number(laneId));
if (!lane) return [];
const started = sql("SELECT started_at FROM sessions WHERE id = ?").get(String(sessionId));
const since = Date.parse(started?.started_at || "") || Date.now() - 24 * 3600 * 1000;
const done = [];
for (const { rel, mtimeMs } of await outputCandidates(lane, since)) {
const rows = sql(
"SELECT * FROM artifacts WHERE kind = 'file' AND lane_id = ? AND source = ?"
).all(lane.id, rel);
if (rows.some((r) => r.auto !== 1)) continue;
const row = rows[0];
const kept = row?.files ? JSON.parse(row.files)[0] : null;
if (kept && kept.mtime === mtimeMs) continue;
let shot;
try {
shot = await snapshotLaneFile(lane, rel);
} catch {
continue; // a secret name, a vanished file, one grown past the cap
}
const files = JSON.stringify([{ path: shot.name, size: shot.size, mtime: mtimeMs }]);
if (row) {
sql(
"UPDATE artifacts SET snapshot = ?, files = ?, size = ?, session_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
).run(shot.snap.id, files, shot.size, String(sessionId), row.id);
dropSnapshot(row.snapshot);
done.push(sql("SELECT * FROM artifacts WHERE id = ?").get(row.id));
} else {
done.push(
insert({
kind: "file",
title: cleanTitle(null, shot.name),
owner_id: lane.owner_id ?? null,
lane_id: lane.id,
session_id: String(sessionId),
source: rel,
snapshot: shot.snap.id,
files,
size: shot.size,
auto: 1,
})
);
}
}
return done;
}
/**
* A file uploaded from the user's computer: kept as a snapshot like a pinned
* one, under a name made from the upload's own (no folder, no path).
@@ -1171,6 +1379,56 @@ function addPlugin(req, { key, market, title, description }) {
// ── change / delete ───────────────────────────────────────────────────────
// ── Unowned rows ──────────────────────────────────────────────────────────
function unownedRow(req, id) {
const row = load(req, id);
if (!isServer()) throw fail("ELOCALMODE", "only server mode has owners");
if (row.owner_id != null) throw fail("EOWNED", "this artifact already has an owner");
return row;
}
/** "It is mine": the suggested user (or an admin, for themselves) takes an unowned row. */
function claim(req, id) {
const row = unownedRow(req, id);
if (req.user.role !== "admin" && row.suggested_owner_id !== req.user.id) {
throw fail("EFORBIDDEN", "only the suggested user or an admin can claim it");
}
sql(
"UPDATE artifacts SET owner_id = ?, suggested_owner_id = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
).run(req.user.id, row.id);
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
audit(req, next, "claim");
return view(next, req);
}
/** "Not mine": the suggestion goes; the row stays unowned for an admin to place. */
function decline(req, id) {
const row = unownedRow(req, id);
if (row.suggested_owner_id !== req.user.id) {
throw fail("EFORBIDDEN", "only the suggested user can decline it");
}
sql("UPDATE artifacts SET suggested_owner_id = NULL WHERE id = ?").run(row.id);
audit(req, row, "decline");
return { id: row.id, declined: true };
}
/** An admin gives an unowned row to an enabled user. */
function assign(req, id, userId) {
const row = unownedRow(req, id);
if (req.user.role !== "admin") throw fail("EFORBIDDEN", "only an admin assigns an owner");
const user = /^\d+$/.test(String(userId))
? sql("SELECT id FROM users WHERE id = ? AND disabled = 0").get(Number(userId))
: null;
if (!user) throw fail("EBADFIELD", "userId: an enabled user is required");
sql(
"UPDATE artifacts SET owner_id = ?, suggested_owner_id = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
).run(user.id, row.id);
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
audit(req, next, "assign");
return view(next, req);
}
function audit(req, row, action) {
if (!req?.user) return;
try {
@@ -1669,6 +1927,11 @@ module.exports = {
canSeeId,
addPage,
captureFromHook,
captureOutputs,
isPageTool,
claim,
decline,
assign,
fileCandidates,
pinFile,
availableSkills,
+2
View File
@@ -1350,6 +1350,7 @@ module.exports = {
laneStatus,
listCommits,
listPaths,
lsFiles,
listTree,
listTreeAll,
parseNumstat,
@@ -1358,6 +1359,7 @@ module.exports = {
readFileAt,
readImageAt,
resolveBaseRef,
walkFolder,
defaultBranch,
DEFAULT_CONTEXT,
MAX_CONTEXT,
+147
View File
@@ -0,0 +1,147 @@
/**
* @file nested-repos.js
* @description The git repositories INSIDE a workspace folder — a parent
* folder holding several repos side by side (`~/work/shop/{api,web,admin}`),
* each with its own `.git` — so the Changes panel can show one section per
* repo, like VS Code's Source Control. Found by reading the disk only (no git
* call): a directory holding a `.git` entry (a directory, or the file a
* submodule or linked worktree has) is a repo, and the walk does not go
* inside one. At most `MAX_DEPTH` levels below the workspace folder, never
* through a symlink, never into a dot directory or `node_modules`/`vendor`,
* at most `MAX_REPOS` repos and `MAX_ENTRIES` directory entries read, so a
* folder with a huge tree (or a member flooding theirs) costs a bounded read.
* Cached per folder for `TTL_MS`; `refresh` reads again.
*
* `repoFolder` is the git routes' `?repo=<path>`: the request names a path
* RELATIVE to the workspace folder, and it is accepted only when it is one of
* the repos this walk found — never a path the walk did not produce, so the
* request can no more point the git routes at an arbitrary directory than
* `?source=` can.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const MAX_DEPTH = 2;
const MAX_REPOS = 50;
const MAX_ENTRIES = 5000;
const TTL_MS = 30_000;
const REREAD_MS = 2_000;
const SKIP = new Set(["node_modules", "vendor", "dist", "build", "target", "__pycache__"]);
const cache = new Map();
function fail(code, message) {
return Object.assign(new Error(message), { code });
}
async function hasGit(dir) {
try {
await fs.promises.lstat(path.join(dir, ".git"));
return true;
} catch {
return false;
}
}
/** Whether `dir` or a folder above it holds a `.git` — the folder is itself in a repo. */
async function insideRepo(dir) {
for (let d = dir; ;) {
if (await hasGit(d)) return true;
const up = path.dirname(d);
if (up === d) return false;
d = up;
}
}
async function walk(root) {
const repos = [];
let budget = MAX_ENTRIES;
let truncated = false;
const visit = async (dir, depth) => {
let handle;
try {
handle = await fs.promises.opendir(dir);
} catch {
return; // unreadable: not ours to report
}
const children = [];
try {
for await (const ent of handle) {
if (--budget < 0) {
truncated = true;
break;
}
// `isDirectory()` is false for a symlink: a link is never followed.
if (!ent.isDirectory() || ent.name.startsWith(".") || SKIP.has(ent.name)) continue;
children.push(ent.name);
}
} catch {
/* a directory that vanished mid-read */
}
children.sort((a, b) => a.localeCompare(b));
for (const name of children) {
if (repos.length >= MAX_REPOS) {
truncated = true;
return;
}
const abs = path.join(dir, name);
if (await hasGit(abs)) {
const rel = path.relative(root, abs).split(path.sep).join("/");
repos.push({ path: rel, name: rel });
} else if (depth < MAX_DEPTH) {
await visit(abs, depth + 1);
}
}
};
await visit(root, 1);
return { items: repos, truncated };
}
/**
* The repos below `cwd`, plus whether `cwd` itself is in a repo (`root`).
* @param {string} cwd The workspace folder (absolute).
* @param {{refresh?: boolean}} [opts]
* @returns {Promise<{root: boolean, items: {path: string, name: string}[], truncated: boolean}>}
*/
async function nestedRepos(cwd, { refresh = false } = {}) {
const hit = cache.get(cwd);
if (!refresh && hit && Date.now() - hit.at < TTL_MS) return hit.value;
const pending = (async () => {
const [root, found] = await Promise.all([insideRepo(cwd), walk(cwd)]);
return { root, ...found };
})();
cache.set(cwd, { at: Date.now(), value: pending });
try {
return await pending;
} catch (err) {
cache.delete(cwd);
throw err;
}
}
/**
* The directory `?repo=<rel>` names: only a repo `nestedRepos` found under
* the workspace folder (re-read if the cached list does not have it — a repo
* cloned in the last 30 s — but at most once per `REREAD_MS`, so a stream of
* bad names cannot keep the disk walking).
* @throws ENOREPO otherwise.
*/
async function repoFolder(cwd, rel) {
const want = String(rel);
const find = (list) => list.items.find((r) => r.path === want);
let hit = find(await nestedRepos(cwd));
if (!hit && Date.now() - (cache.get(cwd)?.at ?? 0) >= REREAD_MS) {
hit = find(await nestedRepos(cwd, { refresh: true }));
}
if (!hit) throw fail("ENOREPO", "that folder is not a repository inside this workspace");
return path.join(cwd, ...hit.path.split("/"));
}
/** Tests only. */
function clearNestedRepoCache() {
cache.clear();
}
module.exports = { nestedRepos, repoFolder, clearNestedRepoCache, MAX_DEPTH, MAX_REPOS };
+38 -1
View File
@@ -28,6 +28,9 @@ const path = require("path");
const { spawn } = require("child_process");
const { getDataDir } = require("./claude-home");
/** Longer than any one step's timeout in the helper (bootstrap: 15 min). */
const PROGRESS_STALE_MS = 20 * 60 * 1000;
/** Runtime directory the helper writes its verdict into. Outside the plugin
* cache on purpose — that directory is replaced by the update itself. */
function runtimeDir() {
@@ -53,6 +56,34 @@ function readLastPluginUpdate(rt = runtimeDir()) {
}
}
/**
* The step a running chain is on (`plugin-update-progress.json`, written by the
* helper as each step starts and removed with its final verdict), or null.
* Surfaced as `plugin_update_progress` so the dashboard can show how far an
* update got. A file older than the chain's longest run is left over from a
* helper that died, and reads as nothing.
*
* @returns {{step: string, index: number, total: number, started_at: string} | null}
*/
function readPluginUpdateProgress(rt = runtimeDir()) {
try {
const parsed = JSON.parse(
fs.readFileSync(path.join(rt, "plugin-update-progress.json"), "utf8")
);
if (!parsed || typeof parsed.step !== "string") return null;
const started = Date.parse(parsed.started_at);
if (!Number.isFinite(started) || Date.now() - started > PROGRESS_STALE_MS) return null;
return {
step: parsed.step,
index: Number(parsed.index) || 0,
total: Number(parsed.total) || 0,
started_at: parsed.started_at,
};
} catch {
return null;
}
}
/**
* Spawn the detached update chain. Returns immediately; the caller must have
* already written its HTTP response, because this process is about to be
@@ -69,6 +100,7 @@ function schedulePluginUpdate({ marketplace, plugin, root }) {
try {
fs.mkdirSync(rt, { recursive: true });
fs.rmSync(path.join(rt, "plugin-update.json"), { force: true });
fs.rmSync(path.join(rt, "plugin-update-progress.json"), { force: true });
} catch {
/* best effort — a stale verdict is better than failing the update */
}
@@ -86,4 +118,9 @@ function schedulePluginUpdate({ marketplace, plugin, root }) {
return { spawned: true, runtime_dir: rt };
}
module.exports = { schedulePluginUpdate, readLastPluginUpdate, runtimeDir };
module.exports = {
schedulePluginUpdate,
readLastPluginUpdate,
readPluginUpdateProgress,
runtimeDir,
};
+94
View File
@@ -0,0 +1,94 @@
/**
* @file subagent-usage — the token usage of a Claude session's sub-agents (the
* `agent-*.jsonl` transcripts Claude Code writes beside the session's own, in
* `<dir>/<session id>/subagents/` or `<dir>/subagents/<session id>/` — the
* same places `findSessionSubagents` in scripts/import-history.js looks),
* summed per pricing bucket and per UTC hour. The hook writer adds it to the
* main transcript's usage, so a session's total and its hours count every
* sub-agent, the same total the importer's `combineSessionTokens` writes
* (both go through the high-water `replaceTokenUsage`, so neither can inflate
* the other). Workflow-tool agents (`subagents/workflows/…`) are left out:
* `workflow-ingest.js` books those under their own service tier. Parsing goes
* through a TranscriptCache of its own (stat-cached, incremental), so the main
* transcripts' cache is never crowded out.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const TranscriptCache = require("./transcript-cache");
const { emptyBucket, accumulateBucket } = require("./token-usage");
const cache = new TranscriptCache(500);
/** The session's sub-agent transcripts, flat ones only. */
function subagentFiles(transcriptPath) {
const dir = path.dirname(transcriptPath);
const sessionId = path.basename(transcriptPath, ".jsonl");
const out = [];
for (const d of [
path.join(dir, sessionId, "subagents"),
path.join(dir, "subagents", sessionId),
]) {
let names;
try {
names = fs.readdirSync(d);
} catch {
continue;
}
for (const n of names) if (n.endsWith(".jsonl")) out.push(path.join(d, n));
}
return out.sort();
}
/** `a` plus `b`, bucket by bucket (an hour bucket keeps its `hour`); never mutates. */
function addTokens(a, b) {
const out = {};
for (const src of [a, b]) {
for (const [key, t] of Object.entries(src || {})) {
if (!out[key]) {
out[key] = emptyBucket(t.model, t.speed, t.geo, t.tier);
if (t.hour) out[key].hour = t.hour;
}
accumulateBucket(out[key], t);
}
}
return out;
}
/**
* The sub-agents' usage of the session whose transcript is `transcriptPath`:
* `{tokensByModel, tokensByHour, parts}` (`parts` = each file's cached parse
* result, the same object while that file is unchanged), or null when it has
* no sub-agent with usage.
*/
function subagentUsage(transcriptPath) {
if (typeof transcriptPath !== "string" || !transcriptPath.endsWith(".jsonl")) return null;
const parts = [];
for (const f of subagentFiles(transcriptPath)) {
let r = null;
try {
r = cache.extract(f);
} catch {
/* unreadable or vanished: skipped this time */
}
if (r && r.tokensByModel) parts.push(r);
}
if (!parts.length) return null;
let tokensByModel = {};
let tokensByHour = {};
for (const r of parts) {
tokensByModel = addTokens(tokensByModel, r.tokensByModel);
tokensByHour = addTokens(tokensByHour, r.tokensByHour);
}
return { tokensByModel, tokensByHour, parts };
}
/** Whether two `parts` lists are the same parses (nothing grew in between). */
function sameParts(a, b) {
const x = a || [];
const y = b || [];
return x.length === y.length && x.every((r, i) => r === y[i]);
}
module.exports = { subagentUsage, subagentFiles, addTokens, sameParts };
+12 -3
View File
@@ -36,7 +36,7 @@
const fs = require("fs");
const path = require("path");
const { execFile } = require("child_process");
const { readLastPluginUpdate } = require("./plugin-update");
const { readLastPluginUpdate, readPluginUpdateProgress } = require("./plugin-update");
const { getClaudeHome } = require("./claude-home");
const DEFAULT_ROOT = path.join(__dirname, "..", "..");
@@ -115,10 +115,16 @@ function execNpm(cwd, args, opts = {}) {
if (!err) return resolve(String(stdout).trim());
if (err.killed) {
return reject(
new Error(`${step} did not finish within ${Math.round(timeout / 60_000)} min and was stopped`)
new Error(
`${step} did not finish within ${Math.round(timeout / 60_000)} min and was stopped`
)
);
}
const tail = String(stderr || err.message || err).trim().split(/\r?\n/).slice(-15).join("\n");
const tail = String(stderr || err.message || err)
.trim()
.split(/\r?\n/)
.slice(-15)
.join("\n");
reject(new Error(`${step} failed:\n${tail}`));
}
);
@@ -491,6 +497,7 @@ async function pluginStatus(root, pluginInstall, options = {}) {
plugin_marketplace: pluginInstall.marketplace,
plugin_name: pluginInstall.plugin,
last_plugin_update: readLastPluginUpdate(),
plugin_update_progress: readPluginUpdateProgress(),
local_version: localVersion,
// Not a clone, so there is nothing to be shallow about and no distances to
@@ -785,6 +792,8 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
// every consumer (route, WS broadcast, CLI) gets it from one place — and so
// a chain that died is visible instead of looking like a slow restart.
last_plugin_update: pluginInstall ? readLastPluginUpdate() : null,
// The step a running chain is on, while it runs.
plugin_update_progress: pluginInstall ? readPluginUpdateProgress() : null,
local_version: localVersion,
// True when commit distances are unusable and version strings are the only
// signal — the UI must not render an "N commits" badge in that case.
+60 -22
View File
@@ -36,9 +36,17 @@ const schemas = {
laneId: { type: "integer", nullable: true },
sessionId: { type: "string", nullable: true },
source: { type: "string", nullable: true, description: "Plugin: `name@marketplace`." },
market: { type: "string", nullable: true, description: "Plugin: owner/repo, git URL or marketplace.json URL." },
market: {
type: "string",
nullable: true,
description: "Plugin: owner/repo, git URL or marketplace.json URL.",
},
url: { type: "string", nullable: true, description: "Page: a https://claude.ai/… link." },
files: { type: "array", items: { type: "object" }, description: "The files the snapshot recorded." },
files: {
type: "array",
items: { type: "object" },
description: "The files the snapshot recorded.",
},
size: { type: "integer", nullable: true },
shared: { type: "boolean" },
sharedAt: { type: "string", nullable: true },
@@ -82,13 +90,23 @@ const paths = {
description:
"A developer sees their own rows plus shared ones; staff see every row in `hub` scope.",
parameters: [
{ name: "scope", in: "query", schema: { type: "string", enum: ["mine", "hub"] } },
{ name: "kind", in: "query", schema: { type: "string", enum: ["page", "file", "skill", "plugin"] } },
{
name: "scope",
in: "query",
schema: { type: "string", enum: ["all", "mine", "hub", "unowned"] },
},
{
name: "kind",
in: "query",
schema: { type: "string", enum: ["page", "file", "skill", "plugin"] },
},
{ name: "q", in: "query", schema: { type: "string" } },
laneQuery,
{ name: "sessionId", in: "query", schema: { type: "string" } },
],
responses: { 200: { description: "`{items}`.", content: { "application/json": { schema: items } } } },
responses: {
200: { description: "`{items}`.", content: { "application/json": { schema: items } } },
},
},
},
"/api/artifacts/settings": {
@@ -186,11 +204,14 @@ const paths = {
tags: ["Artifacts"],
summary: "Keep one uploaded file (≤ 25 MB)",
operationId: "uploadArtifactFile",
requestBody: multipart({
files: { type: "string", format: "binary" },
title: { type: "string" },
description: { type: "string" },
}, ["files"]),
requestBody: multipart(
{
files: { type: "string", format: "binary" },
title: { type: "string" },
description: { type: "string" },
},
["files"]
),
responses: created(item),
},
},
@@ -218,13 +239,16 @@ const paths = {
operationId: "uploadArtifactSkills",
description:
"`paths` is a JSON array of each file's relative path. Zip sizes are read before inflating; nothing is stored if any skill fails.",
requestBody: multipart({
files: { type: "array", items: { type: "string", format: "binary" } },
paths: { type: "string" },
name: { type: "string" },
title: { type: "string" },
description: { type: "string" },
}, ["files"]),
requestBody: multipart(
{
files: { type: "array", items: { type: "string", format: "binary" } },
paths: { type: "string" },
name: { type: "string" },
title: { type: "string" },
description: { type: "string" },
},
["files"]
),
responses: created(items),
},
},
@@ -242,7 +266,10 @@ const paths = {
},
["name", "description", "content"]
),
responses: created({ type: "object", properties: { item: item.properties.item, items: items.properties.items } }),
responses: created({
type: "object",
properties: { item: item.properties.item, items: items.properties.items },
}),
},
},
"/api/artifacts/plugin/inspect": {
@@ -250,10 +277,14 @@ const paths = {
tags: ["Artifacts"],
summary: "Read a marketplace's plugin list",
operationId: "inspectArtifactMarket",
description: "Clones a user-named https link only to read `marketplace.json`; no credential is ever sent.",
description:
"Clones a user-named https link only to read `marketplace.json`; no credential is ever sent.",
requestBody: json({ market: { type: "string" } }, ["market"]),
responses: {
200: { description: "`{market, name, description, plugins: [{name, key, description, version, category}]}`." },
200: {
description:
"`{market, name, description, plugins: [{name, key, description, version, category}]}`.",
},
502: errs,
},
},
@@ -281,7 +312,10 @@ const paths = {
summary: "One artifact",
operationId: "getArtifact",
parameters: [idParam],
responses: { 200: { description: "`{item}`.", content: { "application/json": { schema: item } } }, 404: errs },
responses: {
200: { description: "`{item}`.", content: { "application/json": { schema: item } } },
404: errs,
},
},
patch: {
tags: ["Artifacts"],
@@ -295,7 +329,11 @@ const paths = {
source: { type: "string", description: "Plugin only." },
market: { type: "string", description: "Plugin only." },
}),
responses: { 200: { description: "`{item}`.", content: { "application/json": { schema: item } } }, 403: errs, 404: errs },
responses: {
200: { description: "`{item}`.", content: { "application/json": { schema: item } } },
403: errs,
404: errs,
},
},
delete: {
tags: ["Artifacts"],
+5
View File
@@ -393,6 +393,11 @@ const paths = {
properties: {
workspaces: { type: "integer" },
sessions: { type: "integer" },
today: {
type: "object",
description: "Spent since the viewer's local midnight (`tz_offset`).",
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
},
last7d: {
type: "object",
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
+33
View File
@@ -532,6 +532,39 @@ const paths = {
},
},
},
"/api/lanes/{id}/repos": {
get: {
tags: ["Lanes"],
summary: "The git repositories inside a workspace folder",
description:
"For a parent folder holding several repos (server/lib/nested-repos.js): every directory up to 2 levels below the workspace folder that holds a `.git` entry, never through a symlink, a dot folder or `node_modules`, and never inside a repo already found; at most 50 repos and 5000 directory entries read. Disk reads only (no git call), cached 30 s; `?refresh=1` reads again. Each `path` (relative, `/`-separated) is what the /git and /fs routes take as `?repo=` — a value this walk did not produce answers 404 ENOREPO. `root` says whether the workspace folder itself is in a repo. A missing folder is an empty list.",
operationId: "getLaneRepos",
parameters: [
idParam,
{ name: "refresh", in: "query", required: false, schema: { type: "string", enum: ["1"] } },
],
responses: {
200: {
description: "{root, items: [{path, name}], truncated}.",
...json({
type: "object",
properties: {
root: { type: "boolean" },
items: {
type: "array",
items: {
type: "object",
properties: { path: { type: "string" }, name: { type: "string" } },
},
},
truncated: { type: "boolean" },
},
}),
},
404: notFound,
},
},
},
"/api/lanes/{id}/sources": {
get: {
tags: ["Lanes"],
+3
View File
@@ -801,6 +801,7 @@ const paths = {
{ name: "path", in: "query", required: true, schema: { type: "string" } },
{ name: "session", in: "query", required: false, schema: { type: "string" } },
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
{ name: "repo", in: "query", required: false, schema: { type: "string" } },
],
responses: {
200: { description: "The file's bytes, as an attachment." },
@@ -832,6 +833,7 @@ const paths = {
},
{ name: "session", in: "query", required: false, schema: { type: "string" } },
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
{ name: "repo", in: "query", required: false, schema: { type: "string" } },
],
requestBody: {
required: true,
@@ -874,6 +876,7 @@ const paths = {
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
{ name: "session", in: "query", required: false, schema: { type: "string" } },
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
{ name: "repo", in: "query", required: false, schema: { type: "string" } },
],
responses: {
200: {
+63
View File
@@ -1048,6 +1048,69 @@ const paths = {
},
},
},
"/api/artifacts/{id}/claim": {
post: {
tags: ["Artifacts"],
summary: "Take an unowned artifact",
operationId: "claimArtifact",
description:
"Server mode. The user the row is suggested to (the owner of the workspace or home its session ran in), or an admin, becomes its owner. Audited. Same-origin only.",
parameters: [pathParam("id", "Artifact id.")],
responses: {
200: ok("Claimed.", { item: { type: "object" } }),
400: errs("ELOCALMODE."),
403: errs("EFORBIDDEN: not the suggested user."),
404: errs("ENOTFOUND."),
409: errs("EOWNED: it already has an owner."),
},
},
},
"/api/artifacts/{id}/decline": {
post: {
tags: ["Artifacts"],
summary: "Say an unowned artifact offered to you is not yours",
operationId: "declineArtifact",
description:
"Server mode. Drops the suggestion; the row stays unowned for an admin to place. Audited. Same-origin only.",
parameters: [pathParam("id", "Artifact id.")],
responses: {
200: ok("Declined.", { id: { type: "integer" }, declined: { type: "boolean" } }),
400: errs("ELOCALMODE."),
403: errs("EFORBIDDEN: not the suggested user."),
404: errs("ENOTFOUND."),
409: errs("EOWNED."),
},
},
},
"/api/artifacts/{id}/assign": {
post: {
tags: ["Artifacts"],
summary: "Give an unowned artifact to a user (admin)",
operationId: "assignArtifact",
description:
"Server mode, admin only. Body `{userId}` names an enabled user. Audited. Same-origin only.",
parameters: [pathParam("id", "Artifact id.")],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["userId"],
properties: { userId: { type: "integer" } },
},
},
},
},
responses: {
200: ok("Assigned.", { item: { type: "object" } }),
400: errs("ELOCALMODE / EBADFIELD."),
403: errs("EFORBIDDEN: not an admin."),
404: errs("ENOTFOUND."),
409: errs("EOWNED."),
},
},
},
};
module.exports = { tags, schemas, paths };
+29
View File
@@ -38,6 +38,7 @@ const STATUS = {
EMARKETFETCH: 502,
ENEEDLOGIN: 502,
ETOOBIG: 413,
EOWNED: 409,
};
function sendError(res, err) {
@@ -362,6 +363,34 @@ router.post(
})
);
// A row nobody owns: its suggested user says "mine" or "not mine"; an admin
// takes it or gives it to someone (artifacts.js `claim`/`decline`/`assign`).
router.post(
"/:id/claim",
sameOriginGuard,
wrap((req, res) => {
const item = artifacts.claim(req, req.params.id);
changed(item);
res.json({ item });
})
);
router.post(
"/:id/decline",
sameOriginGuard,
wrap((req, res) => res.json(artifacts.decline(req, req.params.id)))
);
router.post(
"/:id/assign",
sameOriginGuard,
wrap((req, res) => {
const item = artifacts.assign(req, req.params.id, req.body?.userId);
changed(item);
res.json({ item });
})
);
router.post(
"/:id/uninstall",
sameOriginGuard,
+38 -7
View File
@@ -1,5 +1,5 @@
/**
* @file Express router for handling incoming hook events from Claude CLI — and from Codex (`tool: "codex"`, sent through server/lib/codex-hooks.js: such a session is created as a Codex one, its rollout is never parsed as a Claude transcript, a PermissionRequest becomes the permission Notification, and a sub-agent thread's hooks land on the root session). It processes various hook types (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, Notification), updates session and agent states accordingly in the database, extracts token usage from transcripts, detects compaction events, and broadcasts updates to connected clients via WebSocket. `POST /ingest-batch` takes the same hooks pushed from another machine behind a bearer token (server/lib/push-token.js), tagged with that machine as the session source; a hook from a folder the user ignores is dropped whole (server/lib/privacy.js).
* @file Express router for handling incoming hook events from Claude CLI — and from Codex (`tool: "codex"`, sent through server/lib/codex-hooks.js: such a session is created as a Codex one, its rollout is never parsed as a Claude transcript, a PermissionRequest becomes the permission Notification, and a sub-agent thread's hooks land on the root session). It processes various hook types (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, Notification), updates session and agent states accordingly in the database, extracts token usage from transcripts (the session's sub-agent transcripts included, server/lib/subagent-usage.js), detects compaction events, and broadcasts updates to connected clients via WebSocket. `POST /ingest-batch` takes the same hooks pushed from another machine behind a bearer token (server/lib/push-token.js), tagged with that machine as the session source; a hook from a folder the user ignores is dropped whole (server/lib/privacy.js).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -18,6 +18,7 @@ function broadcast(type, data) {
}
const TranscriptCache = require("../lib/transcript-cache");
const { writeHourly } = require("../lib/hourly-usage");
const { subagentUsage, addTokens, sameParts } = require("../lib/subagent-usage");
const { longArgs } = require("../lib/token-usage");
const { scanAndImportSubagents } = require("../../scripts/import-history");
const { evaluateEvent } = require("../lib/alerts");
@@ -987,7 +988,11 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
// Already handled (see transcriptMarks): the same result skips the
// idempotent writes below, a grown one handles only its new entries.
const mark = transcriptMarkFor(sessionId, transcriptPath, session.started_at);
const unchanged = !!mark && mark.result === result;
// The session's sub-agents spend too: their transcripts' usage joins the
// main one's (`lib/subagent-usage.js`), and a sub-agent that grew is a
// change even when the main transcript is not.
const subs = subagentUsage(transcriptPath);
const unchanged = !!mark && mark.result === result && sameParts(mark.subParts, subs?.parts);
const lastOf = (arr) => (arr && arr.length ? arr[arr.length - 1] : null);
// Keep session.model in sync with the user's *current* model — the
@@ -1074,10 +1079,11 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
}
}
if (tokensByModel && !unchanged) {
if ((tokensByModel || subs) && !unchanged) {
// Each bucket carries its pricing dimensions (model/speed/geo/tier) plus
// the 1h cache-write split and server-tool request counts.
for (const tokens of Object.values(tokensByModel)) {
const allTokens = subs ? addTokens(tokensByModel, subs.tokensByModel) : tokensByModel;
for (const tokens of Object.values(allTokens)) {
stmts.replaceTokenUsage.run(
sessionId,
tokens.model,
@@ -1105,7 +1111,10 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
);
}
}
writeHourly(sessionId, result.tokensByHour);
writeHourly(
sessionId,
subs ? addTokens(result.tokensByHour, subs.tokensByHour) : result.tokensByHour
);
}
// Register API errors from transcript (quota limits, rate limits, overloaded, etc.)
@@ -1229,6 +1238,7 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
queueTranscriptMark(sessionId, transcriptPath, {
result,
subParts: subs?.parts ?? null,
startedAt: session.started_at,
compactionUuid: lastOf(compaction?.entries)?.uuid ?? null,
lastError: lastOf(result.errors),
@@ -1295,9 +1305,13 @@ const processEventTx = db.transaction((hookType, data, tool = "claude", peer = n
};
broadcast("new_event", event);
// A claude.ai page the session published goes into its owner's Artifacts.
if (hookType === "PostToolUse" && toolName === "Artifact" && tool !== "codex") {
// A Claude Docs doc counts too (`isPageTool`): it is a claude.ai page the session made.
if (hookType === "PostToolUse" && tool !== "codex") {
try {
const row = require("../lib/artifacts").captureFromHook(sessionId, data, peer);
const artifacts = require("../lib/artifacts");
const row = artifacts.isPageTool(toolName)
? artifacts.captureFromHook(sessionId, data, peer)
: null;
if (row) {
broadcast("artifact_changed", { id: row.id, ownerId: row.owner_id, shared: false });
}
@@ -1736,6 +1750,23 @@ router.post("/event", (req, res) => {
});
}
// Documents the session made (slides, Word, Excel, PDF, HTML) go into its
// workspace owner's Artifacts when it stops. Off the response path, fail-safe.
if (["Stop", "SessionEnd"].includes(hook_type) && data.session_id) {
require("../lib/artifacts")
.captureOutputs(data.session_id)
.then((rows) => {
for (const row of rows) {
broadcast("artifact_changed", {
id: row.id,
ownerId: row.owner_id,
shared: row.shared === 1,
});
}
})
.catch((err) => console.warn("[artifacts] output capture failed:", err.message));
}
// Ingest Workflow-tool run journals from disk. Inner agent() calls emit NO
// hooks, so the journal (written at workflow completion) is the only source.
// Run on the lifecycle hooks that bracket a workflow finishing, off the
+44 -5
View File
@@ -17,6 +17,7 @@ const { db } = require("../db");
const lanesLib = require("../lib/lanes");
const { sessionWorktree, removeSessionWorktree } = require("../lib/session-worktree");
const { sourceFolder } = require("../lib/lane-sources");
const { nestedRepos, repoFolder } = require("../lib/nested-repos");
const { suggestCommitMessage } = require("../lib/commit-message");
const aiTools = require("../lib/ai-tools");
const {
@@ -886,9 +887,19 @@ router.post("/clone", sameOriginGuard, async (req, res) => {
});
if (!done) return;
const { target, name, ownerId } = done;
const cwd = lanesLib.canonicalCwd(target);
// A workspace can outlive its folder (deleted from the File Explorer or a
// shell): cloning into that path again re-attaches the user's own record
// rather than tripping idx_lanes_cwd_tool. Another user's was refused above.
const strip = (p) => p.replace(/[\\/]+$/, "");
const found = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude");
if (found && strip(found.cwd) === strip(cwd) && scope.canSeeLane(req, found.id)) {
broadcastLane(found.id);
return res.status(200).json({ lane: payload(found), created: false, path: target });
}
try {
const lane = lanesLib.createLane({
cwd: lanesLib.canonicalCwd(target),
cwd,
owner_id: ownerId,
title: typeof body.title === "string" && body.title.trim() ? body.title.trim() : name,
...(body.tool ? { tool: body.tool } : {}),
@@ -1305,6 +1316,23 @@ router.get("/:id/integrations/:name", (req, res) => {
* POST and Express matches on method as well as path. Verified by moving this
* registration after it: the suite stayed green.
*/
/**
* The git repositories inside the workspace folder (`server/lib/nested-repos.js`):
* a parent folder of several repos gets one Changes-panel section per repo,
* each read and written through `?repo=<path>`. `root` says whether the
* workspace folder is itself in a repo. Disk reads only, cached 30 s;
* `?refresh=1` reads again. A missing folder is an empty list, not a fault.
*/
router.get("/:id/repos", async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
try {
res.json(await nestedRepos(lane.cwd, { refresh: req.query.refresh === "1" }));
} catch {
res.json({ root: false, items: [], truncated: false });
}
});
router.get("/:id/git", async (req, res) => {
const t = await gitTargetOr404(req, res);
if (!t) return;
@@ -2416,14 +2444,25 @@ function gitErrorCode(err) {
* `?session=<id>` — that session's worktree (`claude --worktree`), resolved by
* `sessionWorktree` from the session ROW (owned by this workspace, registered
* by `git worktree list`), or — with `?source=<id>` — one of the workspace's
* extra folders, resolved by `sourceFolder` from its `lane_sources` ROW; never
* from a path in the request. An unresolvable session (400) or source (404,
* like another workspace's row) is never a silent fall back to the main
* folder: a commit or a discard must never land in the wrong tree.
* extra folders, resolved by `sourceFolder` from its `lane_sources` ROW, or —
* with `?repo=<path>` — a repo inside the workspace folder, accepted only when
* `repoFolder`'s own walk of that folder found it; never from a path in the
* request. An unresolvable session (400), source or repo (404, like another
* workspace's row) is never a silent fall back to the main folder: a commit
* or a discard must never land in the wrong tree.
*/
async function gitTargetOr404(req, res) {
const lane = laneOr404(req, res);
if (!lane) return null;
if (req.query.repo !== undefined) {
try {
const cwd = await repoFolder(lane.cwd, req.query.repo);
return { lane, cwd, worktree: null, source: true };
} catch (err) {
res.status(404).json({ error: { code: err.code, message: err.message } });
return null;
}
}
if (req.query.source !== undefined) {
try {
const cwd = sourceFolder(lane.id, req.query.source);
+7 -2
View File
@@ -6,7 +6,7 @@
* the setup wizard shows once per account. Local mode is one account,
* `local`; server mode keys a row `u<id>` by the login cookie — the request
* never names an account. Only the keys in `PREF_KEYS` are kept, each a
* string of at most 8 KB, and a PUT merges into the row (`null` removes a
* string of at most 8 KB (the sidebar's workspace groups 64 KB), and a PUT merges into the row (`null` removes a
* key). Writes are same-origin and touch only the caller's own row.
* `/instructions` is the account's own agent instructions (issue #67, at most
* 16 000 characters, blank clears), put before the workspace's in every pane
@@ -68,8 +68,13 @@ const PREF_KEYS = [
"agenthub.chatUi",
"agenthub.workspace.allTabs",
"agenthub.markdown.view",
"agenthub.workspace.groups",
"agenthub.changes.innerRepos",
];
const MAX_VALUE = 8 * 1024;
// The sidebar's workspace groups (lane id → group, JSON) outgrow 8 KB long
// before any other key would.
const MAX_FOR = { "agenthub.workspace.groups": 64 * 1024 };
/** `u<id>` for a server-mode account, `local` in local mode, null when nobody is signed in. */
function userKey(req) {
@@ -122,7 +127,7 @@ router.put("/prefs", sameOriginGuard, (req, res) => {
return res.status(400).json({ error: { code: "EBADPREFS", message: `unknown key ${k}` } });
}
if (v === null) delete prefs[k];
else if (typeof v === "string" && v.length <= MAX_VALUE) prefs[k] = v;
else if (typeof v === "string" && v.length <= (MAX_FOR[k] ?? MAX_VALUE)) prefs[k] = v;
else
return res
.status(400)
+58 -48
View File
@@ -2,9 +2,10 @@
* @file users.js
* @description `/api/users` — server mode's per-user view for an admin or
* analyst (members are refused by STAFF_ONLY_PREFIXES in server/lib/auth.js):
* `GET /` lists every account with its workspaces, sessions and $ spent in the
* last 7 and 30 days (from `token_usage_hourly`, sessions owned through their
* workspace — server/lib/scope.js); `GET /audit` the newest audit entries
* `GET /` lists every account with its workspaces, sessions and $ spent today
* (the viewer's local day, `?tz_offset=` = the browser's getTimezoneOffset())
* and in the last 7 and 30 days (`hourlyTokenRows` + `calculateCost`, sessions owned
* through their workspace — server/lib/scope.js); `GET /audit` the newest audit entries
* (server/lib/audit.js); `GET /storage` how much disk each user's home takes
* (server/lib/user-storage.js — measured in a worker, `?refresh=1` re-measures).
*
@@ -41,22 +42,6 @@ const { listAudit, recordAccountChange } = require("../lib/audit");
const router = Router();
const USAGE_COLS = [
"input_tokens",
"output_tokens",
"cache_read_tokens",
"cache_write_tokens",
"cache_write_1h_tokens",
"web_search_requests",
"web_fetch_requests",
"code_execution_requests",
"long_input_tokens",
"long_output_tokens",
"long_cache_read_tokens",
"long_cache_write_tokens",
"long_cache_write_1h_tokens",
];
// Which account each session belongs to — through its workspace, or a
// dashboard run on one (server/lib/scope.js's sessionWhere, for every user at once).
const OWNED_SESSIONS = `
@@ -68,41 +53,63 @@ const OWNED_SESSIONS = `
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id JOIN sessions s ON s.id = r.session_id
WHERE l.owner_id IS NOT NULL`;
/** Map(user id → {cost, tokens}) every account's sessions spent since `sinceHour` (UTC `YYYY-MM-DDTHH`). */
function usageSince(sinceHour, rules) {
const { calculateCost } = require("./pricing");
const rows = db
.prepare(
`WITH owned AS (${OWNED_SESSIONS})
SELECT o.uid, h.model, h.speed, h.inference_geo, h.service_tier,
${USAGE_COLS.map((c) => `SUM(h.${c}) AS ${c}`).join(", ")}
FROM token_usage_hourly h JOIN owned o ON o.session_id = h.session_id
WHERE h.hour >= ?
GROUP BY o.uid, h.model, h.speed, h.inference_geo, h.service_tier`
)
.all(sinceHour);
const byUser = new Map();
for (const r of rows) {
if (!byUser.has(r.uid)) byUser.set(r.uid, []);
byUser.get(r.uid).push(r);
/**
* Map(user id → {cost, tokens}) per window, each window a UTC hour
* (`YYYY-MM-DDTHH`) it starts at. Spend is the pricing routes' own booking
* (`hourlyTokenRows`: a session's hours, the rest of its total at its start
* hour — so Codex/omp/Antigravity and sub-agent usage count too) priced by
* `calculateCost`, never a second formula.
*/
function usageSince(windows, rules) {
const { calculateCost, hourlyTokenRows } = require("./pricing");
const oldest = windows.reduce((a, b) => (b < a ? b : a));
const owner = new Map();
for (const r of db.prepare(OWNED_SESSIONS).all()) {
if (!owner.has(r.session_id)) owner.set(r.session_id, r.uid);
}
const out = new Map();
for (const [uid, list] of byUser) {
const tokens = list.reduce(
(n, r) => n + r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens,
0
);
out.set(uid, { cost: calculateCost(list, rules).total_cost, tokens });
}
return out;
// A session that spent in the window was touched in it (budgets.js does the same).
const rows = hourlyTokenRows(
`WHERE COALESCE(NULLIF(s.updated_at, ''), s.started_at) >= ? AND s.id IN (SELECT session_id FROM (${OWNED_SESSIONS}))`,
[oldest]
);
return windows.map((since) => {
const byUser = new Map();
for (const r of rows) {
const uid = owner.get(r.session_id);
if (uid == null || r.date < since) continue;
if (!byUser.has(uid)) byUser.set(uid, []);
byUser.get(uid).push(r);
}
const out = new Map();
for (const [uid, list] of byUser) {
const tokens = list.reduce(
(n, r) => n + r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens,
0
);
out.set(uid, { cost: calculateCost(list, rules).total_cost, tokens: Math.round(tokens) });
}
return out;
});
}
const hourAgo = (days) => new Date(Date.now() - days * 864e5).toISOString().slice(0, 13);
/**
* The UTC hour the viewer's local day began in — `offset` is the browser's
* getTimezoneOffset() (minutes local time is BEHIND UTC; UTC when missing).
* Buckets are whole UTC hours, so a half-hour zone's day starts up to 30 min early.
*/
function todayHour(offset) {
const off = Number.isFinite(offset) && Math.abs(offset) <= 840 ? offset : 0;
const local = new Date(Date.now() - off * 60e3);
local.setUTCHours(0, 0, 0, 0);
return new Date(local.getTime() + off * 60e3).toISOString().slice(0, 13);
}
/** Map(key → n) from rows of `{k, n}`. */
const counts = (rows) => new Map(rows.map((r) => [r.k, r.n]));
router.get("/", (_req, res) => {
router.get("/", (req, res) => {
const rules = stmts.listPricing.all();
// A fixed number of grouped queries, however many accounts there are.
const logins = auth.allLoginStats();
@@ -112,14 +119,17 @@ router.get("/", (_req, res) => {
const sessions = counts(
db.prepare(`SELECT uid AS k, COUNT(*) AS n FROM (${OWNED_SESSIONS}) GROUP BY uid`).all()
);
const last7d = usageSince(hourAgo(7), rules);
const last30d = usageSince(hourAgo(30), rules);
const [today, last7d, last30d] = usageSince(
[todayHour(parseInt(req.query.tz_offset, 10)), hourAgo(7), hourAgo(30)],
rules
);
const none = { cost: 0, tokens: 0 };
const users = listUsers().map((u) => ({
...u,
...(logins.get(u.id) || { logins: 0, lastLoginAt: null }),
workspaces: workspaces.get(u.id) || 0,
sessions: sessions.get(u.id) || 0,
today: today.get(u.id) || none,
last7d: last7d.get(u.id) || none,
last30d: last30d.get(u.id) || none,
}));